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
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:
return LearnableLanguage(
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 .translate import router as translate_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.include_router(account_router)
api_router.include_router(pos_router)
api_router.include_router(translate_router)
api_router.include_router(generation_router)

View file

@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy.exc import IntegrityError
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.repositories import user_repository
@ -27,24 +27,15 @@ class TokenResponse(BaseModel):
@router.post("/register", status_code=status.HTTP_201_CREATED)
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
# email is implemented. Set is_email_verified=False on the User model and
# 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)