feat: Endpoints to manage your account.
This commit is contained in:
parent
689e10d1bc
commit
0281caef7c
6 changed files with 290 additions and 16 deletions
14
api/app/domain/models/account.py
Normal file
14
api/app/domain/models/account.py
Normal 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)
|
||||||
150
api/app/domain/services/account_service.py
Normal file
150
api/app/domain/services/account_service.py
Normal 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")
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
97
api/app/routers/api/account.py
Normal file
97
api/app/routers/api/account.py
Normal 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))
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue