Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Users module."""
+52
View File
@@ -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")
+164
View File
@@ -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)
+59
View File
@@ -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)
+34
View File
@@ -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)
+31
View File
@@ -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)