init: new structure + fix lint errors

This commit is contained in:
Daniil
2026-02-03 02:15:07 +03:00
commit 67e0f22b4f
89 changed files with 7654 additions and 0 deletions
View File
+36
View File
@@ -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)
+76
View File
@@ -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()
+180
View File
@@ -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"
)
+75
View File
@@ -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
+92
View File
@@ -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)