Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ repos:

- repo: local
hooks:
- id: prettier-frontend
name: prettier (frontend)
entry: bash -c 'cd frontend && args=(); for f in "$@"; do args+=("${f#frontend/}"); done; npx prettier --write --ignore-unknown "${args[@]}"' --
language: system
files: ^frontend/.*\.(js|jsx|json|css|md)$
pass_filenames: true
- id: eslint-frontend
name: eslint (frontend)
entry: bash -c 'cd frontend && npm run lint'
entry: bash -c 'cd frontend && args=(); for f in "$@"; do args+=("${f#frontend/}"); done; npx eslint --fix "${args[@]}" && npx eslint "${args[@]}"' --
language: system
files: ^frontend/src/.*\.(js|jsx)$
pass_filenames: false
files: ^frontend/(src/.*\.(js|jsx)|eslint\.config\.js)$
pass_filenames: true
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ This project is a ready-to-use fullstack template that leverages Docker Compose
./scripts/setup-pre-commit.sh
```

Hooks auto-fix on commit: backend (Ruff format + lint fix), frontend (Prettier + ESLint fix), then re-stage if files changed.

re-enable hooks:

```bash
Expand Down
52 changes: 48 additions & 4 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,63 @@ This backend project is built with modern Python technologies to provide a robus
- 🐳 Easy containerization with Docker
- ✅ Async Testing & coverage with a fully isolated test environment

## Lint
## Lint & format

Requires [uv](https://docs.astral.sh/uv/) and dev dependencies (`uv sync`).
### Standards

| Item | Value |
|------|--------|
| Config | [`pyproject.toml`](./pyproject.toml) — `[tool.ruff]`, `[tool.ruff.lint]`, `[tool.ruff.format]` |
| Formatter | [Ruff format](https://docs.astral.sh/ruff/formatter/) (Black-compatible) |
| Line length | 100 |
| Indent | 4 spaces; tabs are rewritten on format |
| Quotes | Double quotes |
| Target Python | 3.14 |

| Rule set | Source | Purpose |
|----------|--------|---------|
| `E` | pycodestyle | PEP 8 style |
| `F` | Pyflakes | Unused imports, syntax issues |
| `I` | isort | Import order |
| `UP` | pyupgrade | Modern Python syntax |
| `B` | flake8-bugbear | Common bug patterns |

| Ignored | Reason |
|---------|--------|
| `B008` | FastAPI `Depends()` in default arguments |
| `B904` | Exception chaining in FastAPI handlers |
| `E712` | SQLAlchemy boolean checks; auto-fix breaks ORM queries |

| Per-file ignored | Path | Reason |
|------------------|------|--------|
| `B` | `tests/**` | Relaxed bugbear rules in tests |
| `E402` | `main.py`, `core/config.py`, `migrations/env.py` | Imports after bootstrap / env setup |
| `E501` | `utils/email_templates.py` | Long HTML email template lines |

### Manual commands

> Run from the `backend/` directory. Requires [uv](https://docs.astral.sh/uv/) and dev dependencies (`uv sync`).

Lint the project; report issues without changing files:

```bash
cd backend
uv run ruff check .
```

Check formatting only; report mismatches without writing:

```bash
uv run ruff format --check .
```

Auto-fix:
Lint and auto-fix what Ruff can (imports, safe rewrites):

```bash
uv run ruff check --fix .
```

Apply formatting to all Python files (including tab → spaces):

```bash
uv run ruff format .
```
9 changes: 6 additions & 3 deletions backend/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
from fastapi import APIRouter

from core.config import settings
from .auth.controller import router as auth_router

from .account.controller import router as account_router
from .users.controller import router as users_router
from .auth.controller import router as auth_router
from .roles.controller import router as roles_router
from .users.controller import router as users_router

api_router = APIRouter()

if settings.DEBUG_MODE:
from .debug.controller import router as debug_router

api_router.include_router(debug_router, prefix="/debug")

# Add new API modules below.
api_router.include_router(auth_router, prefix="/auth")
api_router.include_router(account_router, prefix="/account")
api_router.include_router(users_router, prefix="/users")
api_router.include_router(roles_router, prefix="/roles")
api_router.include_router(roles_router, prefix="/roles")
94 changes: 55 additions & 39 deletions backend/api/account/controller.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
from core.redis import get_redis
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession

from core.dependencies import get_db
from core.redis import get_redis
from core.security import verify_token
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, Response
from .schema import UserProfile, UserUpdate, PasswordChange
from utils.response import APIResponse, parse_responses, common_responses, make_error_examples
from .services import get_user_by_id, update_user_profile, change_password
from extensions.smtp import SMTPMailer, get_mailer
from utils.custom_exception import AuthenticationException, NotFoundException
from extensions.smtp import get_mailer, SMTPMailer
from utils.response import APIResponse, common_responses, make_error_examples, parse_responses

from .schema import PasswordChange, UserProfile, UserUpdate
from .services import change_password, get_user_by_id, update_user_profile

router = APIRouter(tags=["Account"])


def _to_user_profile(user) -> UserProfile:
return UserProfile(
id=user.id,
Expand All @@ -23,74 +26,76 @@ def _to_user_profile(user) -> UserProfile:
created_at=user.created_at,
)


@router.get(
"/profile",
response_model=APIResponse[UserProfile],
summary="Get current user profile",
responses=parse_responses({
200: ("User profile retrieved successfully", UserProfile)
}, common_responses)
responses=parse_responses(
{200: ("User profile retrieved successfully", UserProfile)}, common_responses
),
)
async def get_user_profile_api(
token: dict = Depends(verify_token),
db: AsyncSession = Depends(get_db)
token: dict = Depends(verify_token), db: AsyncSession = Depends(get_db)
):
"""
Get the current authenticated user's profile information.
"""
try:
user_id = token.get("sub")
user = await get_user_by_id(db, user_id)

if not user:
raise NotFoundException("User not found")

user_data = _to_user_profile(user)

return APIResponse(code=200, message="User profile retrieved successfully", data=user_data)
except NotFoundException:
raise HTTPException(status_code=404, detail="User not found")
except Exception:
raise HTTPException(status_code=500)


@router.put(
"/profile",
response_model=APIResponse[UserProfile],
response_model_exclude_unset=True,
summary="Update current user profile",
responses=parse_responses({
200: ("User profile updated successfully", UserProfile),
202: ("Email verification required", UserProfile)
}, common_responses)
responses=parse_responses(
{
200: ("User profile updated successfully", UserProfile),
202: ("Email verification required", UserProfile),
},
common_responses,
),
)
async def update_user_profile_api(
user_update: UserUpdate,
response: Response,
token: dict = Depends(verify_token),
db: AsyncSession = Depends(get_db),
redis_client = Depends(get_redis),
mailer: SMTPMailer = Depends(get_mailer)
redis_client=Depends(get_redis),
mailer: SMTPMailer = Depends(get_mailer),
):
"""
Update the current authenticated user's profile information (excluding password).
"""
try:
user_id = token.get("sub")
result = await update_user_profile(
db, user_id, user_update, mailer, redis_client
)

result = await update_user_profile(db, user_id, user_update, mailer, redis_client)

if not result:
raise NotFoundException("User not found")

user, email_change_requested = result

user_data = _to_user_profile(user)

if email_change_requested:
response.status_code = 202
return APIResponse(code=202, message="Email verification required", data=user_data)

return APIResponse(code=200, message="User profile updated successfully", data=user_data)
except NotFoundException:
raise HTTPException(status_code=404, detail="User not found")
Expand All @@ -99,24 +104,35 @@ async def update_user_profile_api(
except Exception:
raise HTTPException(status_code=500)


@router.put(
"/password",
response_model=APIResponse[None],
response_model_exclude_unset=True,
summary="Change current user password",
responses=parse_responses({
200: ("Password changed successfully", None),
401: ("Unauthorized", None, make_error_examples(401, {
"invalidToken": "Invalid or expired token",
"incorrectPassword": "Current password is incorrect",
})),
}, common_responses)
responses=parse_responses(
{
200: ("Password changed successfully", None),
401: (
"Unauthorized",
None,
make_error_examples(
401,
{
"invalidToken": "Invalid or expired token",
"incorrectPassword": "Current password is incorrect",
},
),
),
},
common_responses,
),
)
async def change_user_password_api(
password_change: PasswordChange,
token: dict = Depends(verify_token),
db: AsyncSession = Depends(get_db),
redis_client = Depends(get_redis)
redis_client=Depends(get_redis),
):
"""
Change the current authenticated user's password.
Expand All @@ -131,11 +147,11 @@ async def change_user_password_api(
redis_client,
current_session_id=current_session_id,
)

if success:
return APIResponse(code=200, message="Password changed successfully")

except AuthenticationException:
raise HTTPException(status_code=401, detail="Current password is incorrect")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))
34 changes: 23 additions & 11 deletions backend/api/account/schema.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,45 @@
from typing import Optional
from datetime import datetime
from core.config import settings

from pydantic import BaseModel, EmailStr, Field, model_validator

from core.config import settings


class UserProfile(BaseModel):
id: str = Field(..., description="User ID")
first_name: str = Field(..., description="First name")
last_name: str = Field(..., description="Last name")
email: EmailStr = Field(..., description="User email address")
pending_email: Optional[EmailStr] = Field(None, description="Pending email awaiting verification")
pending_email: EmailStr | None = Field(None, description="Pending email awaiting verification")
phone: str = Field(..., description="Phone number")
status: bool = Field(..., description="User status")
created_at: datetime = Field(..., description="User creation time")


class UserUpdate(BaseModel):
first_name: Optional[str] = Field(None, min_length=1, max_length=50, description="First name")
last_name: Optional[str] = Field(None, min_length=1, max_length=50, description="Last name")
email: Optional[EmailStr] = Field(None, description="User email address")
phone: Optional[str] = Field(None, min_length=1, max_length=20, description="Phone number")
first_name: str | None = Field(None, min_length=1, max_length=50, description="First name")
last_name: str | None = Field(None, min_length=1, max_length=50, description="Last name")
email: EmailStr | None = Field(None, description="User email address")
phone: str | None = Field(None, min_length=1, max_length=20, description="Phone number")


class PasswordChange(BaseModel):
current_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH, max_length=50, description="Current password")
new_password: str = Field(..., min_length=settings.PASSWORD_MIN_LENGTH, max_length=50, description="New password")
current_password: str = Field(
..., min_length=settings.PASSWORD_MIN_LENGTH, max_length=50, description="Current password"
)
new_password: str = Field(
..., min_length=settings.PASSWORD_MIN_LENGTH, max_length=50, description="New password"
)
logout_other_devices: bool = Field(True, description="Logout other devices")

@model_validator(mode="before")
@classmethod
def migrate_legacy_logout_field(cls, data):
if isinstance(data, dict) and "logout_other_devices" not in data and "logout_all_devices" in data:
if (
isinstance(data, dict)
and "logout_other_devices" not in data
and "logout_all_devices" in data
):
data = data.copy()
data["logout_other_devices"] = data.pop("logout_all_devices")
return data
return data
Loading
Loading