Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
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,31 @@
|
||||
from app.core.jwt_denylist import bump_auth_epoch
|
||||
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)
|
||||
bump_auth_epoch(user.id)
|
||||
revoke_user_refresh_family(user.id)
|
||||
Reference in New Issue
Block a user