feat: Endpoints to manage your account.

This commit is contained in:
wilson 2026-04-08 20:50:26 +01:00
parent 689e10d1bc
commit 0281caef7c
6 changed files with 290 additions and 16 deletions

View file

@ -0,0 +1,14 @@
from dataclasses import dataclass, field
from datetime import datetime
from .learnable_language import LearnableLanguage
@dataclass
class Account:
id: str
email: str
is_active: bool
is_email_verified: bool
created_at: datetime
learnable_languages: list[LearnableLanguage] = field(default_factory=list)

View file

@ -0,0 +1,150 @@
import uuid
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..models.account import Account
from ..models.learnable_language import LearnableLanguage
from ...auth import hash_password
from ...outbound.postgres.entities.user_entity import User as UserEntity
from ...outbound.postgres.repositories import learnable_language_repository, user_repository
class AccountService:
"""Handles account-level operations: registration, profile retrieval, and managing
the set of languages a user is learning.
All methods operate on behalf of a single authenticated user (or, for
``create_account``, the user being created).
Usage::
service = AccountService(db)
# Registration
account = await service.create_account("alice@example.com", "s3cr3t")
# Profile retrieval
account = await service.get_account(user_id)
print(account.learnable_languages) # [LearnableLanguage(...), ...]
# Add French (B1) to the account
lang = await service.add_learnable_language(
user_id, source_language="en", target_language="fr", proficiencies=["B1"]
)
# Remove it again
await service.remove_learnable_language(user_id, lang.id)
"""
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create_account(self, email: str, password: str) -> Account:
"""Create a new user account, hashing the plain-text password before storage.
Raises ``ValueError`` if the email address is already registered, so the
caller does not need to catch SQLAlchemy exceptions directly.
Usage::
try:
account = await service.create_account("alice@example.com", "s3cr3t")
except ValueError:
# email already taken
...
"""
try:
user = await user_repository.create(
self.db,
email=email,
hashed_password=hash_password(password),
)
except IntegrityError:
await self.db.rollback()
raise ValueError("Email already registered")
return Account(
id=str(user.id),
email=user.email,
is_active=user.is_active,
is_email_verified=user.is_email_verified,
created_at=user.created_at,
)
async def get_account(self, user_id: uuid.UUID) -> Account:
"""Retrieve a user's account profile including all their learnable languages.
Raises ``ValueError`` if no user exists for the given ``user_id``.
Usage::
account = await service.get_account(user_id)
for lang in account.learnable_languages:
print(lang.target_language, lang.proficiencies)
"""
# user_repository only exposes get_by_email; query by id directly
result = await self.db.execute(
select(UserEntity).where(UserEntity.id == user_id)
)
user = result.scalar_one_or_none()
if user is None:
raise ValueError(f"User {user_id} not found")
languages = await learnable_language_repository.list_for_user(self.db, user_id)
return Account(
id=str(user.id),
email=user.email,
is_active=user.is_active,
is_email_verified=user.is_email_verified,
created_at=user.created_at,
learnable_languages=languages,
)
async def add_learnable_language(
self,
user_id: uuid.UUID,
source_language: str,
target_language: str,
proficiencies: list[str],
) -> LearnableLanguage:
"""Add a language pair to the user's account, or update proficiency levels if
the pair already exists (upsert semantics).
Usage::
lang = await service.add_learnable_language(
user_id,
source_language="en",
target_language="fr",
proficiencies=["B1", "B2"],
)
print(lang.id) # UUID of the learnable_language row
"""
return await learnable_language_repository.upsert(
self.db,
user_id=user_id,
source_language=source_language,
target_language=target_language,
proficiencies=proficiencies,
)
async def remove_learnable_language(
self, user_id: uuid.UUID, language_id: uuid.UUID
) -> None:
"""Remove a learnable language from the user's account by its row ID.
Raises ``ValueError`` if the language entry does not exist or does not belong
to ``user_id``.
Usage::
await service.remove_learnable_language(user_id, lang.id)
"""
deleted = await learnable_language_repository.delete(
self.db, user_id=user_id, language_id=language_id
)
if not deleted:
raise ValueError(f"Learnable language {language_id} not found for this user")

View file

@ -7,6 +7,26 @@ from ..entities.learnable_language_entity import LearnableLanguageEntity
from ....domain.models.learnable_language import LearnableLanguage from ....domain.models.learnable_language import LearnableLanguage
async def delete(db: AsyncSession, user_id: uuid.UUID, language_id: uuid.UUID) -> bool:
"""Delete a learnable language row owned by ``user_id``.
Returns ``True`` if a row was deleted, ``False`` if no matching row was found.
The ``user_id`` check prevents one user from deleting another's data.
"""
result = await db.execute(
select(LearnableLanguageEntity).where(
LearnableLanguageEntity.id == language_id,
LearnableLanguageEntity.user_id == user_id,
)
)
entity = result.scalar_one_or_none()
if entity is None:
return False
await db.delete(entity)
await db.commit()
return True
def _to_model(entity: LearnableLanguageEntity) -> LearnableLanguage: def _to_model(entity: LearnableLanguageEntity) -> LearnableLanguage:
return LearnableLanguage( return LearnableLanguage(
id=str(entity.id), id=str(entity.id),

View file

@ -0,0 +1,97 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
from ...auth import verify_token
from ...domain.services.account_service import AccountService
from ...languages import SUPPORTED_LANGUAGES, SUPPORTED_LEVELS
from ...outbound.postgres.database import get_db
router = APIRouter(prefix="/account", tags=["account"])
class AddLearnableLanguageRequest(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(
"/learnable-languages",
response_model=LearnableLanguageResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_learnable_language(
body: AddLearnableLanguageRequest,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> LearnableLanguageResponse:
if body.source_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported source language '{body.source_language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
)
if body.target_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported target language '{body.target_language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
)
if body.source_language == body.target_language:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="source_language and target_language must differ",
)
user_id = uuid.UUID(token_data["sub"])
lang = await AccountService(db).add_learnable_language(
user_id=user_id,
source_language=body.source_language,
target_language=body.target_language,
proficiencies=body.proficiencies,
)
return LearnableLanguageResponse(
id=lang.id,
source_language=lang.source_language,
target_language=lang.target_language,
proficiencies=lang.proficiencies,
)
@router.delete(
"/learnable-languages/{language_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def remove_learnable_language(
language_id: str,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> None:
try:
lid = uuid.UUID(language_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid language_id")
user_id = uuid.UUID(token_data["sub"])
try:
await AccountService(db).remove_learnable_language(user_id=user_id, language_id=lid)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))

View file

@ -1,3 +1,4 @@
from .account import router as account_router
from .pos import router as pos_router 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
@ -9,6 +10,7 @@ from fastapi import APIRouter
api_router = APIRouter(prefix="/api", tags=["api"]) api_router = APIRouter(prefix="/api", tags=["api"])
api_router.include_router(account_router)
api_router.include_router(pos_router) 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)

View file

@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ..auth import create_access_token, hash_password, verify_password from ..auth import create_access_token, verify_password
from ..domain.services.account_service import AccountService
from ..outbound.postgres.database import get_db from ..outbound.postgres.database import get_db
from ..outbound.postgres.repositories import user_repository from ..outbound.postgres.repositories import user_repository
@ -27,24 +27,15 @@ class TokenResponse(BaseModel):
@router.post("/register", status_code=status.HTTP_201_CREATED) @router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)): async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
try:
user = await user_repository.create(
db,
email=body.email,
hashed_password=hash_password(body.password),
)
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email already registered",
)
# TODO(email-verification): send verification email here once transactional # TODO(email-verification): send verification email here once transactional
# email is implemented. Set is_email_verified=False on the User model and # email is implemented. Set is_email_verified=False on the User model and
# require verification before allowing login. # require verification before allowing login.
try:
account = await AccountService(db).create_account(body.email, body.password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
return {"id": str(user.id), "email": user.email} return {"id": account.id, "email": account.email}
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)