An async, layered REST API for managing properties, units, tenants, employees, rent, and renovations — built with FastAPI and SQLAlchemy 2.0 (async).
Status: Early stage. The User slice (model → schema → repository → service → security → dependency wiring) and the Auth router (
register/login/me) are complete end to end. Property, Unit, Tenant, Lease, and Payment resources are scaffolded but not yet implemented.
- Tech Stack
- Architecture
- How It Works
- Project Structure
- Current Feature Status
- API Endpoints
- Getting Started
- Database & Migrations
- Testing, Linting & Formatting
- Coding Standards
- Git Workflow
- Custom Slash Commands
- Roadmap
- License
| Concern | Choice |
|---|---|
| Language / runtime | Python 3.13 |
| Package manager | uv |
| Web framework | FastAPI |
| ORM | SQLAlchemy 2.0 — async (create_async_engine, async_sessionmaker, AsyncSession) |
| Database driver | asyncpg |
| Database | PostgreSQL 17 (via Docker) |
| Migrations | Alembic (async env.py) |
| Validation / settings | Pydantic v2 (pydantic[email]) + pydantic-settings |
| Authentication | JWT (OAuth2 password flow) via PyJWT |
| Password hashing | pwdlib[bcrypt] (PasswordHash((BcryptHasher(),))) |
| Testing | pytest + pytest-asyncio + httpx |
| Lint / format (CI-enforced) | ruff (ruff check, ruff format --check) |
| Lint / format (also available) | flake8, black |
| CI | GitHub Actions, PostgreSQL service container |
Deliberately not used: python-jose (unmaintained, replaced by PyJWT) and
passlib (incompatible with bcrypt ≥ 4.1, replaced by pwdlib). No sync
SQLAlchemy engine anywhere — the asyncpg driver requires the async engine
end to end.
The project follows a strict four-layer architecture. Each layer only talks to the one directly below it:
HTTP request
│
▼
routers/ FastAPI path operations — request/response only, no logic
│ Depends(get_..._service)
▼
services/ Business rules, orchestration, password hashing calls
│ self.repo.<query>()
▼
repositories/ Async SQLAlchemy queries — no business logic, no HTTP
│ AsyncSession
▼
models/ SQLAlchemy ORM classes — table schema only
│
▼
PostgreSQL (via asyncpg)
schemas/ (Pydantic v2) sits at the HTTP boundary alongside routers/ —
it validates incoming JSON and shapes outgoing responses, but is not part of
the call chain above.
core/ is the cross-cutting layer every other layer depends on:
| File | Responsibility |
|---|---|
core/config.py |
Settings — loads and validates every environment variable via pydantic-settings |
core/security.py |
The only module allowed to import pwdlib or jwt. Exposes exactly four helpers: hash_password, verify_password, create_access_token, verify_token |
core/dependencies.py |
Composition layer: wires get_db → get_user_repository → get_user_service, and owns authentication (oauth2_scheme, get_current_user, get_current_active_superuser) |
database.py is the async database foundation: the engine
(create_async_engine, pool_pre_ping=True), the AsyncSessionLocal
session factory, the Base declarative class every model inherits from, and
the get_db() FastAPI dependency. No queries or business logic live there.
A typical request, e.g. GET /api/v1/auth/me with a Bearer token:
app/main.pyreceives the request and routes it torouters/auth.py.- FastAPI resolves
Depends(get_current_user)fromcore/dependencies.py:oauth2_schemeextracts the raw JWT from theAuthorizationheader (401 automatically if missing).verify_token()(incore/security.py) checks the signature and expiry, and returns the decoded payload — any failure is a uniform 401 so clients never learn why a token was rejected.- The
subclaim (a string) is parsed back into an integer user id and loaded viaUserService.get_user_by_id. - A deactivated account (
is_active=False) short-circuits with 400.
- The router hands the resolved
Userstraight back — no business logic lives in the router itself. - The response is filtered through
UserResponse(schemas/user.py), which deliberately has nohashed_passwordfield, so secrets can never leak through a response model.
Login (POST /api/v1/auth/login) works the other direction: the router
calls UserService.authenticate_user(), which looks up the user by email
and verifies the password via verify_password() — using the same error
message for "no such user" and "wrong password" to prevent email
enumeration. Only once identity is confirmed does the router (not the
service) call create_access_token({"sub": str(user.id)}) and return a
Token.
Registration (POST /api/v1/auth/register) hashes the password with
hash_password() before it ever reaches the repository, and hardcodes
is_superuser=False in the service layer regardless of what a client sends.
.
├── app/
│ ├── main.py # FastAPI app, lifespan, CORS, router mounting
│ ├── database.py # Async engine, session factory, Base, get_db()
│ ├── core/
│ │ ├── config.py # Settings (env-driven)
│ │ ├── security.py # Password hashing + JWT (only module touching pwdlib/jwt)
│ │ └── dependencies.py # DI wiring + auth dependencies
│ ├── models/ # SQLAlchemy ORM models
│ │ ├── user.py # Implemented
│ │ └── properties.py, units.py, tenants.py, leases.py, payments.py # Empty — not yet scaffolded
│ ├── schemas/
│ │ └── user.py # UserBase/Create/Update/Response/InDB + Token
│ ├── repositories/
│ │ ├── user_repository.py # Implemented
│ │ └── tenant_repository.py, unit_repository.py # Docstring stubs only
│ ├── services/
│ │ └── user_service.py # Implemented — create/authenticate/get/update/delete/list
│ └── routers/
│ └── auth.py # Implemented — register, login, me
├── migrations/
│ ├── env.py # Async Alembic environment
│ └── versions/ # f166860421d8_create_users_table.py
├── tests/
│ ├── conftest.py
│ └── test_smoke.py # Config-loading smoke tests
├── .github/workflows/ci.yml # Lint + format + test on every push/PR to main
├── docker-compose.yml # Local PostgreSQL 17 container
├── alembic.ini
├── pyproject.toml
└── CLAUDE.md # Full engineering conventions for AI-assisted work
| Resource | Model | Schema | Repository | Service | Router |
|---|---|---|---|---|---|
| User / Auth | ✅ | ✅ | ✅ | ✅ | ✅ (/api/v1/auth/*) |
| Property | — | — | — | — | — |
| Unit | — | — | — | — | — (repository docstring stub only) |
| Tenant | — | — | — | — | — (repository docstring stub only) |
| Lease | — | — | — | — | — |
| Payment | — | — | — | — | — |
| Method | Path | Auth required | Description |
|---|---|---|---|
| GET | / |
No | Welcome message + doc links |
| GET | /health |
No | Liveness + live DB connectivity check |
| POST | /api/v1/auth/register |
No | Create a new user account → 201 UserResponse |
| POST | /api/v1/auth/login |
No | OAuth2 password flow login → 200 Token (JWT) |
| GET | /api/v1/auth/me |
Yes (Bearer) | Return the authenticated user's profile |
Interactive docs: /docs (Swagger UI, with an Authorize button wired
to the OAuth2 password flow) and /redoc.
- Python 3.13
- uv
- Docker + Docker Compose
git clone git@github.com:NouhJama/property-management-api.git
cd property-management-api
cp .env.example .envFill in .env — Settings (app/core/config.py) requires:
| Variable | Purpose |
|---|---|
APP_NAME, APP_VERSION |
Shown in /, /health, and the OpenAPI docs |
DEBUG |
Enables SQL echo logging and FastAPI debug mode |
DATABASE_URL |
Must be postgresql+asyncpg://... — asyncpg driver only |
SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES |
JWT signing config |
ALLOWED_ORIGINS |
CORS allow-list |
Never hard-code credentials elsewhere — everything reads through Settings.
docker compose up -d
docker compose ps # confirm property_db is healthyuv syncuv run alembic upgrade headuv run uvicorn app.main:app --reloadThen open http://localhost:8000/docs.
Every new SQLAlchemy model requires two manual steps before Alembic can see it — this is the most common Alembic mistake in this project:
- Import the model class in
migrations/env.pyunder the model-imports block. - Generate the migration:
# Generate a migration after changing or adding a model
uv run alembic revision --autogenerate -m "describe the change"
# Review the generated file in migrations/versions/, then apply it
uv run alembic upgrade head
# Roll back the last migration
uv run alembic downgrade -1
# Show the current migration
uv run alembic current
# Verify models and DB are in sync
uv run alembic check# Run the test suite
uv run pytest tests/ -v
# Lint (enforced in CI)
uv run ruff check .
# Format check (enforced in CI)
uv run ruff format --check .
uv run ruff format . # auto-fix
# Also available per CLAUDE.md conventions
uv run black app/ tests/
uv run flake8 app/ tests/CI (.github/workflows/ci.yml) runs on every push and PR to main: it spins
up a PostgreSQL 17 service container, then runs ruff check,
ruff format --check, and the full pytest suite.
- PEP 8, max line length 100.
- Descriptive names;
Annotated[...]-style type hints on every function signature and variable annotation. - Docstrings on every function and class.
- Async end to end — no sync
create_engine()/sessionmaker()calls. - FastAPI:
Annotateddependencies,lifespancontext manager (not the deprecated@app.on_event), Pydantic v2 response models. - Pydantic v2 only:
model_validator,field_validator,ConfigDict— never v1's@validator/class Config. - Strict layering: repositories only query, services only orchestrate, routers only wire HTTP to services. No layer reaches past the one below it.
- All routes are prefixed
api/v1/.... - Before writing or editing code that touches FastAPI, SQLAlchemy, Pydantic, or Alembic, fetch current docs via the Context7 MCP server rather than relying on training data — these libraries move fast.
GitHub Flow — one protected branch (main); every feature merges via PR.
git branch --show-current
# If on main:
git checkout main && git pull origin main
git checkout -b feature/describe-the-task| Prefix | Use for |
|---|---|
feature/short-description |
New features |
fix/what-you-are-fixing |
Bug fixes |
chore/what-you-are-doing |
Config, tooling |
test/what-you-are-testing |
Tests only |
docs/what-you-are-writing |
Documentation |
git checkout main && git pull origin main
git checkout -b feature/task-name
# do the work
git add . && git commit -m "type: description"
git push origin feature/task-name
# open PR on GitHub → review → merge → delete branch- Never commit directly to
main. - Never force-push to
main. - Never skip the PR — even for a one-line change.
Project-specific Claude Code commands live in .claude/commands/:
| Command | Purpose |
|---|---|
/new-endpoint <resource> |
Scaffolds a new resource (schema, model, repository, service, router, test) as docstring-only stubs across all layers — no real logic unless explicitly requested |
/sync-main <branch> |
Post-merge cleanup: switch to main, pull, safe-delete the merged local branch, prune stale remote refs |
- Implement
Property,Unit,Tenant,Lease, andPaymentmodels - Implement their repositories, services, and routers (currently stubs or entirely empty)
- Wire remaining routers into
app/main.py - Expand
tests/beyond config smoke tests — unit + integration coverage per resource - Rent tracking and renovation tracking business logic
MIT — see LICENSE.