Update admin theme/layout and refresh README details.
Align the project baseline with the latest admin interface styling and layout structure while documenting setup and usage updates in README.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Users module."""
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
|
||||
is_superuser: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
profile: Mapped["UserProfile"] = relationship(
|
||||
back_populates="user",
|
||||
uselist=False,
|
||||
cascade="all, delete-orphan",
|
||||
passive_deletes=True,
|
||||
)
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "user_profiles"
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="profile")
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.users.models import User, UserProfile
|
||||
|
||||
ALLOWED_ROLES = {"user", "admin"}
|
||||
ALLOWED_STATUSES = {"pending", "active", "blocked"}
|
||||
|
||||
|
||||
def _detach(db, instance):
|
||||
db.refresh(instance)
|
||||
db.expunge(instance)
|
||||
return instance
|
||||
|
||||
|
||||
def create_user(
|
||||
email: str,
|
||||
password_hash: str,
|
||||
role: str = "user",
|
||||
is_superuser: bool = False,
|
||||
status: str = "pending",
|
||||
) -> User:
|
||||
if role not in ALLOWED_ROLES:
|
||||
raise ValueError("INVALID_ROLE")
|
||||
if status not in ALLOWED_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
if is_superuser and role != "admin":
|
||||
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
|
||||
with session_scope() as db:
|
||||
user = User(
|
||||
id=str(uuid4()),
|
||||
email=email.lower(),
|
||||
password_hash=password_hash,
|
||||
role=role,
|
||||
is_superuser=is_superuser,
|
||||
status=status,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
profile = UserProfile(user_id=user.id, display_name=email.split("@")[0])
|
||||
db.add(profile)
|
||||
db.flush()
|
||||
return _detach(db, user)
|
||||
|
||||
|
||||
def get_user_by_email(email: str) -> User | None:
|
||||
with session_scope() as db:
|
||||
user = db.scalar(select(User).where(User.email == email.lower()))
|
||||
if not user:
|
||||
return None
|
||||
return _detach(db, user)
|
||||
|
||||
|
||||
def get_user_by_id(user_id: str) -> User | None:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
return None
|
||||
return _detach(db, user)
|
||||
|
||||
|
||||
def update_user(user: User) -> None:
|
||||
if user.role not in ALLOWED_ROLES:
|
||||
raise ValueError("INVALID_ROLE")
|
||||
if user.status not in ALLOWED_STATUSES:
|
||||
raise ValueError("INVALID_STATUS")
|
||||
if user.is_superuser and user.role != "admin":
|
||||
raise ValueError("SUPERUSER_REQUIRES_ADMIN")
|
||||
with session_scope() as db:
|
||||
db_user = db.get(User, user.id)
|
||||
if not db_user:
|
||||
return
|
||||
db_user.email = user.email
|
||||
db_user.password_hash = user.password_hash
|
||||
db_user.role = user.role
|
||||
db_user.is_superuser = user.is_superuser
|
||||
db_user.status = user.status
|
||||
db_user.failed_login_attempts = user.failed_login_attempts
|
||||
db_user.locked_until = user.locked_until
|
||||
db_user.email_verified_at = user.email_verified_at
|
||||
db_user.updated_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def list_users(page: int, limit: int) -> tuple[list[User], int]:
|
||||
with session_scope() as db:
|
||||
total = db.scalar(select(func.count()).select_from(User)) or 0
|
||||
users = db.scalars(
|
||||
select(User).order_by(User.created_at.desc()).offset((page - 1) * limit).limit(limit)
|
||||
).all()
|
||||
return [_detach(db, user) for user in users], total
|
||||
|
||||
|
||||
def count_users() -> int:
|
||||
with session_scope() as db:
|
||||
return db.scalar(select(func.count()).select_from(User)) or 0
|
||||
|
||||
|
||||
def count_users_registered_today() -> int:
|
||||
today = datetime.now(UTC).date()
|
||||
with session_scope() as db:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(func.date(User.created_at) == today)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def count_admins() -> int:
|
||||
with session_scope() as db:
|
||||
return db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0
|
||||
|
||||
|
||||
def count_superusers() -> int:
|
||||
with session_scope() as db:
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.role == "admin", User.is_superuser.is_(True))
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def delete_user(user_id: str) -> bool:
|
||||
with session_scope() as db:
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
return False
|
||||
db.delete(user)
|
||||
return True
|
||||
|
||||
|
||||
def get_profile(user_id: str) -> UserProfile:
|
||||
with session_scope() as db:
|
||||
profile = db.get(UserProfile, user_id)
|
||||
if not profile:
|
||||
raise KeyError(user_id)
|
||||
return _detach(db, profile)
|
||||
|
||||
|
||||
def update_profile(
|
||||
user_id: str,
|
||||
display_name: str | None = None,
|
||||
avatar_url: str | None = None,
|
||||
) -> UserProfile:
|
||||
with session_scope() as db:
|
||||
profile = db.get(UserProfile, user_id)
|
||||
if not profile:
|
||||
raise KeyError(user_id)
|
||||
if display_name is not None:
|
||||
profile.display_name = display_name
|
||||
if avatar_url is not None:
|
||||
profile.avatar_url = avatar_url
|
||||
db.flush()
|
||||
return _detach(db, profile)
|
||||
@@ -0,0 +1,59 @@
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.core.media_signing import build_signed_media_url
|
||||
from app.core.redis import check_rate_limit
|
||||
from app.modules.media.service import AvatarValidationError
|
||||
from app.modules.users.schemas import PasswordChangeIn, UserPatchIn
|
||||
from app.modules.users.service import change_password, get_me, update_me, upload_avatar
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _profile_response(data: dict) -> dict:
|
||||
return {
|
||||
"user": {
|
||||
"id": data["user"].id,
|
||||
"email": data["user"].email,
|
||||
"role": data["user"].role,
|
||||
"status": data["user"].status,
|
||||
},
|
||||
"profile": {
|
||||
"display_name": data["profile"].display_name,
|
||||
"avatar_url": build_signed_media_url(data["profile"].avatar_url),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me_route(current_user=Depends(get_current_user)):
|
||||
return _profile_response(get_me(current_user))
|
||||
|
||||
|
||||
@router.patch("/me")
|
||||
async def patch_me_route(payload: UserPatchIn, current_user=Depends(get_current_user)):
|
||||
return _profile_response(update_me(current_user, payload.display_name))
|
||||
|
||||
|
||||
@router.post("/me/password")
|
||||
async def change_password_route(payload: PasswordChangeIn, current_user=Depends(get_current_user)):
|
||||
try:
|
||||
change_password(current_user, payload.current_password, payload.new_password)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="INVALID_CURRENT_PASSWORD")
|
||||
return {"message": "password_changed"}
|
||||
|
||||
|
||||
@router.post("/me/avatar")
|
||||
async def upload_avatar_route(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
check_rate_limit(f"avatar:{current_user.id}", limit=10, window_seconds=3600)
|
||||
content = await file.read()
|
||||
try:
|
||||
data = upload_avatar(current_user, content)
|
||||
except AvatarValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return _profile_response(data)
|
||||
@@ -0,0 +1,34 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
from app.core.password_policy import validate_password_strength
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
role: str
|
||||
status: str
|
||||
|
||||
|
||||
class UserProfileOut(BaseModel):
|
||||
display_name: str
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
class UserMeOut(BaseModel):
|
||||
user: UserOut
|
||||
profile: UserProfileOut
|
||||
|
||||
|
||||
class UserPatchIn(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
|
||||
|
||||
class PasswordChangeIn(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def password_policy(cls, value: str) -> str:
|
||||
return validate_password_strength(value)
|
||||
@@ -0,0 +1,29 @@
|
||||
from app.core.security import hash_password, verify_password
|
||||
from app.modules.auth.service import revoke_user_refresh_family
|
||||
from app.modules.media.service import upload_user_avatar
|
||||
from app.modules.users import repository
|
||||
from app.modules.users.models import User
|
||||
|
||||
|
||||
def get_me(user: User) -> dict:
|
||||
profile = repository.get_profile(user.id)
|
||||
return {"user": user, "profile": profile}
|
||||
|
||||
|
||||
def update_me(user: User, display_name: str | None) -> dict:
|
||||
profile = repository.update_profile(user.id, display_name=display_name)
|
||||
return {"user": user, "profile": profile}
|
||||
|
||||
|
||||
def upload_avatar(user: User, content: bytes) -> dict:
|
||||
avatar_url = upload_user_avatar(user.id, content)
|
||||
profile = repository.update_profile(user.id, avatar_url=avatar_url)
|
||||
return {"user": user, "profile": profile}
|
||||
|
||||
|
||||
def change_password(user: User, current_password: str, new_password: str) -> None:
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
raise ValueError("INVALID_CURRENT_PASSWORD")
|
||||
user.password_hash = hash_password(new_password)
|
||||
repository.update_user(user)
|
||||
revoke_user_refresh_family(user.id)
|
||||
Reference in New Issue
Block a user