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
|
||||
|
||||
|
||||
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),
|
||||
|
|
|
|||
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 .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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue