init: new structure + fix lint errors
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from cpv3.db.base import Base, BaseModelMixin
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class User(Base, BaseModelMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
username: Mapped[str] = mapped_column(String(150), unique=True, index=True)
|
||||
email: Mapped[str] = mapped_column(String(254), default="")
|
||||
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
first_name: Mapped[str] = mapped_column(String(150), default="")
|
||||
last_name: Mapped[str] = mapped_column(String(150), default="")
|
||||
|
||||
phone_number: Mapped[str | None] = mapped_column(String(15), unique=True, nullable=True)
|
||||
avatar: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
phone_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
is_staff: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
date_joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.modules.users.models import User
|
||||
from cpv3.modules.users.schemas import UserCreate, UserRegister, UserUpdate
|
||||
from cpv3.infrastructure.security import hash_password
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Repository for User database operations."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||
result = await self._session.execute(select(User).where(User.id == user_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_username(self, username: str) -> User | None:
|
||||
result = await self._session.execute(
|
||||
select(User).where(User.username == username)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_all(self, *, requester: User) -> list[User]:
|
||||
stmt: Select[tuple[User]] = select(User)
|
||||
if not requester.is_staff:
|
||||
stmt = stmt.where(User.id == requester.id)
|
||||
|
||||
result = await self._session.execute(stmt.order_by(User.created_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, *, data: UserCreate | UserRegister) -> User:
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
first_name=data.first_name,
|
||||
last_name=data.last_name,
|
||||
phone_number=data.phone_number,
|
||||
avatar=data.avatar,
|
||||
)
|
||||
|
||||
self._session.add(user)
|
||||
try:
|
||||
await self._session.commit()
|
||||
except IntegrityError as e:
|
||||
await self._session.rollback()
|
||||
raise ValueError("User already exists or violates constraints") from e
|
||||
|
||||
await self._session.refresh(user)
|
||||
return user
|
||||
|
||||
async def update(self, user: User, data: UserUpdate) -> User:
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(user, key, value)
|
||||
|
||||
try:
|
||||
await self._session.commit()
|
||||
except IntegrityError as e:
|
||||
await self._session.rollback()
|
||||
raise ValueError("Update violates constraints") from e
|
||||
|
||||
await self._session.refresh(user)
|
||||
return user
|
||||
|
||||
async def deactivate(self, user: User) -> None:
|
||||
user.is_active = False
|
||||
await self._session.commit()
|
||||
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from jwt import ExpiredSignatureError, InvalidTokenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.infrastructure.auth import get_current_user
|
||||
from cpv3.infrastructure.security import create_token, decode_token
|
||||
from cpv3.infrastructure.settings import get_settings
|
||||
from cpv3.db.session import get_db
|
||||
from cpv3.modules.users.models import User
|
||||
from cpv3.modules.users.schemas import (
|
||||
TokenRefresh,
|
||||
TokenRefreshResponse,
|
||||
UserCreate,
|
||||
UserLogin,
|
||||
UserRead,
|
||||
UserRegister,
|
||||
UserRegisterResponse,
|
||||
UserUpdate,
|
||||
)
|
||||
from cpv3.modules.users.service import UserService
|
||||
|
||||
users_router = APIRouter(prefix="/api/users", tags=["Users"])
|
||||
auth_router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _issue_tokens(user: User) -> tuple[str, str]:
|
||||
settings = get_settings()
|
||||
|
||||
access = create_token(
|
||||
subject=str(user.id),
|
||||
token_type="access",
|
||||
expires_in=timedelta(minutes=settings.jwt_access_ttl_minutes),
|
||||
extra={"is_staff": user.is_staff, "is_superuser": user.is_superuser},
|
||||
)
|
||||
refresh = create_token(
|
||||
subject=str(user.id),
|
||||
token_type="refresh",
|
||||
expires_in=timedelta(days=settings.jwt_refresh_ttl_days),
|
||||
)
|
||||
return access, refresh
|
||||
|
||||
|
||||
@users_router.get("/", response_model=list[UserRead])
|
||||
async def list_all_users(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[UserRead]:
|
||||
service = UserService(db)
|
||||
users = await service.list_users(requester=current_user)
|
||||
return [UserRead.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@users_router.post("/", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user_endpoint(
|
||||
body: UserCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserRead:
|
||||
service = UserService(db)
|
||||
try:
|
||||
user = await service.create_user(body, requester=current_user)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
return UserRead.model_validate(user)
|
||||
|
||||
|
||||
@users_router.get("/me/", response_model=UserRead)
|
||||
async def me(current_user: User = Depends(get_current_user)) -> UserRead:
|
||||
return UserRead.model_validate(current_user)
|
||||
|
||||
|
||||
@users_router.get("/{user_id}/", response_model=UserRead)
|
||||
async def retrieve_user(
|
||||
user_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserRead:
|
||||
service = UserService(db)
|
||||
user = await service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
if not current_user.is_staff and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
return UserRead.model_validate(user)
|
||||
|
||||
|
||||
@users_router.patch("/{user_id}/", response_model=UserRead)
|
||||
async def patch_user(
|
||||
user_id: uuid.UUID,
|
||||
body: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserRead:
|
||||
service = UserService(db)
|
||||
user = await service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
if not current_user.is_staff and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
try:
|
||||
user = await service.update_user(user, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
return UserRead.model_validate(user)
|
||||
|
||||
|
||||
@users_router.delete("/{user_id}/", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
service = UserService(db)
|
||||
user = await service.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
if not current_user.is_staff and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
await service.deactivate_user(user)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@auth_router.post(
|
||||
"/register", response_model=UserRegisterResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def register(body: UserRegister, db: AsyncSession = Depends(get_db)) -> UserRegisterResponse:
|
||||
service = UserService(db)
|
||||
try:
|
||||
user = await service.register_user(body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) from e
|
||||
|
||||
access, refresh = _issue_tokens(user)
|
||||
return UserRegisterResponse(user=UserRead.model_validate(user), access=access, refresh=refresh)
|
||||
|
||||
|
||||
@auth_router.post("/login", response_model=UserRegisterResponse)
|
||||
async def login(body: UserLogin, db: AsyncSession = Depends(get_db)) -> UserRegisterResponse:
|
||||
service = UserService(db)
|
||||
user = await service.authenticate(body.username, body.password)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
access, refresh = _issue_tokens(user)
|
||||
return UserRegisterResponse(user=UserRead.model_validate(user), access=access, refresh=refresh)
|
||||
|
||||
|
||||
@auth_router.post("/refresh", response_model=TokenRefreshResponse)
|
||||
async def refresh(body: TokenRefresh) -> TokenRefreshResponse:
|
||||
try:
|
||||
payload = decode_token(body.refresh)
|
||||
if payload.get("type") != "refresh":
|
||||
raise InvalidTokenError("wrong type")
|
||||
|
||||
user_id = uuid.UUID(str(payload.get("sub")))
|
||||
|
||||
settings = get_settings()
|
||||
access = create_token(
|
||||
subject=str(user_id),
|
||||
token_type="access",
|
||||
expires_in=timedelta(minutes=settings.jwt_access_ttl_minutes),
|
||||
)
|
||||
return TokenRefreshResponse(access=access, refresh=body.refresh)
|
||||
except (ExpiredSignatureError, InvalidTokenError, ValueError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from cpv3.common.schemas import Schema
|
||||
|
||||
|
||||
class UserRead(Schema):
|
||||
id: UUID
|
||||
username: str
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
phone_number: str | None
|
||||
avatar: str | None
|
||||
|
||||
email_verified: bool
|
||||
phone_verified: bool
|
||||
|
||||
is_active: bool
|
||||
is_staff: bool
|
||||
is_superuser: bool
|
||||
|
||||
date_joined: datetime
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserCreate(Schema):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
phone_number: str | None = None
|
||||
avatar: str | None = None
|
||||
|
||||
|
||||
class UserUpdate(Schema):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
phone_number: str | None = None
|
||||
avatar: str | None = None
|
||||
|
||||
|
||||
class UserRegister(Schema):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
first_name: str = ""
|
||||
last_name: str = ""
|
||||
phone_number: str | None = None
|
||||
avatar: str | None = None
|
||||
|
||||
|
||||
class UserLogin(Schema):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserRegisterResponse(Schema):
|
||||
user: UserRead
|
||||
access: str
|
||||
refresh: str
|
||||
|
||||
|
||||
class TokenRefresh(Schema):
|
||||
refresh: str
|
||||
|
||||
|
||||
class TokenRefreshResponse(Schema):
|
||||
access: str
|
||||
refresh: str
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from cpv3.infrastructure.security import verify_password
|
||||
from cpv3.modules.users.models import User
|
||||
from cpv3.modules.users.repository import UserRepository
|
||||
from cpv3.modules.users.schemas import UserCreate, UserRegister, UserUpdate
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Service for user business logic and orchestration."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._repo = UserRepository(session)
|
||||
|
||||
async def get_user_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||
return await self._repo.get_by_id(user_id)
|
||||
|
||||
async def get_user_by_username(self, username: str) -> User | None:
|
||||
return await self._repo.get_by_username(username)
|
||||
|
||||
async def list_users(self, *, requester: User) -> list[User]:
|
||||
return await self._repo.list_all(requester=requester)
|
||||
|
||||
async def create_user(self, data: UserCreate, *, requester: User | None) -> User:
|
||||
# Keep Django behavior: any authenticated user can create via this endpoint.
|
||||
if requester is None:
|
||||
raise ValueError("Authentication required")
|
||||
return await self._repo.create(data=data)
|
||||
|
||||
async def register_user(self, data: UserRegister) -> User:
|
||||
return await self._repo.create(data=data)
|
||||
|
||||
async def update_user(self, user: User, data: UserUpdate) -> User:
|
||||
return await self._repo.update(user, data)
|
||||
|
||||
async def deactivate_user(self, user: User) -> None:
|
||||
await self._repo.deactivate(user)
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> User | None:
|
||||
user = await self._repo.get_by_username(username)
|
||||
if user is None:
|
||||
return None
|
||||
if not user.is_active:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
# Legacy function exports for backward compatibility
|
||||
async def get_user_by_id(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||
service = UserService(session)
|
||||
return await service.get_user_by_id(user_id)
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> User | None:
|
||||
service = UserService(session)
|
||||
return await service.get_user_by_username(username)
|
||||
|
||||
|
||||
async def list_users(session: AsyncSession, *, requester: User) -> list[User]:
|
||||
service = UserService(session)
|
||||
return await service.list_users(requester=requester)
|
||||
|
||||
|
||||
async def create_user(session: AsyncSession, data: UserCreate, *, requester: User | None) -> User:
|
||||
service = UserService(session)
|
||||
return await service.create_user(data, requester=requester)
|
||||
|
||||
|
||||
async def register_user(session: AsyncSession, data: UserRegister) -> User:
|
||||
service = UserService(session)
|
||||
return await service.register_user(data)
|
||||
|
||||
|
||||
async def update_user(session: AsyncSession, user: User, data: UserUpdate) -> User:
|
||||
service = UserService(session)
|
||||
return await service.update_user(user, data)
|
||||
|
||||
|
||||
async def deactivate_user(session: AsyncSession, user: User) -> None:
|
||||
service = UserService(session)
|
||||
await service.deactivate_user(user)
|
||||
|
||||
|
||||
async def authenticate(session: AsyncSession, username: str, password: str) -> User | None:
|
||||
service = UserService(session)
|
||||
return await service.authenticate(username, password)
|
||||
Reference in New Issue
Block a user