202 lines
5.9 KiB
Markdown
202 lines
5.9 KiB
Markdown
# Python Web Stack Standards
|
|
|
|
## Stack
|
|
|
|
- **API:** FastAPI (async)
|
|
- **ORM:** async SQLAlchemy 2.0 with ORM models (not raw queries)
|
|
- **Database:** MySQL 8
|
|
- **Runtime:** Docker Compose
|
|
- **Reverse proxy:** Nginx
|
|
- **Container management:** Portainer
|
|
|
|
## Project Layout
|
|
|
|
```
|
|
project-name/
|
|
├── docker-compose.yml
|
|
├── .env.example # Template — always include this, never commit .env
|
|
├── .gitignore
|
|
├── README.md
|
|
├── nginx/
|
|
│ └── default.conf
|
|
└── backend/
|
|
├── Dockerfile
|
|
├── requirements.txt
|
|
└── app/
|
|
├── main.py # App entry point, router registration, startup events
|
|
├── config.py # Settings loaded from environment via pydantic-settings
|
|
├── database.py # Async SQLAlchemy engine and session factory
|
|
├── dependencies.py # Shared FastAPI dependencies (auth, db session, etc.)
|
|
├── models/ # SQLAlchemy ORM table definitions
|
|
├── schemas/ # Pydantic request/response models
|
|
├── routers/ # API endpoint handlers, one file per resource
|
|
└── utils/ # Shared helpers
|
|
```
|
|
|
|
## Configuration & Secrets
|
|
|
|
- All secrets and environment-specific values go in `.env` — never hardcode them
|
|
- Always provide `.env.example` with placeholder values and comments explaining each variable
|
|
- Load settings via `pydantic-settings`:
|
|
|
|
```python
|
|
# app/config.py
|
|
from pydantic_settings import BaseSettings
|
|
|
|
class Settings(BaseSettings):
|
|
db_host: str
|
|
db_port: int = 3306
|
|
db_name: str
|
|
db_user: str
|
|
db_password: str
|
|
secret_key: str
|
|
|
|
class Config:
|
|
env_file = ".env"
|
|
|
|
settings = Settings()
|
|
```
|
|
|
|
## Database (SQLAlchemy 2.0 Async)
|
|
|
|
- Use async engine and session factory
|
|
- Define models with `DeclarativeBase`
|
|
- One model per file in `models/`
|
|
|
|
```python
|
|
# app/database.py
|
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
|
from sqlalchemy.orm import DeclarativeBase
|
|
|
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
|
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
class Base(DeclarativeBase):
|
|
pass
|
|
```
|
|
|
|
- Inject DB sessions via FastAPI dependencies (not global sessions)
|
|
- Use `async with session.begin()` for transactions
|
|
|
|
## Models
|
|
|
|
- One file per table in `models/`
|
|
- Use typed columns with SQLAlchemy 2.0 `Mapped` syntax:
|
|
|
|
```python
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from app.database import Base
|
|
|
|
class User(Base):
|
|
__tablename__ = "users"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
username: Mapped[str] = mapped_column(unique=True)
|
|
email: Mapped[str]
|
|
```
|
|
|
|
## Schemas (Pydantic)
|
|
|
|
- Separate schemas for Create, Update, and Response — don't reuse the same schema for all three
|
|
- Response schemas should never expose password hashes or internal fields
|
|
|
|
```python
|
|
class UserCreate(BaseModel): ...
|
|
class UserResponse(BaseModel):
|
|
model_config = ConfigDict(from_attributes=True)
|
|
```
|
|
|
|
## Routers
|
|
|
|
- One router file per resource (e.g., `routers/users.py`, `routers/auth.py`)
|
|
- Register all routers in `main.py` with a consistent prefix and tags
|
|
|
|
## Docker Compose
|
|
|
|
- Use named volumes for persistent data (database, uploads)
|
|
- Always define a `healthcheck` for the database service
|
|
- Use `depends_on` with `condition: service_healthy` for the backend
|
|
- Keep the Nginx config in a `nginx/` folder and mount it as a volume
|
|
|
|
```yaml
|
|
services:
|
|
db:
|
|
image: mysql:8
|
|
healthcheck:
|
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
backend:
|
|
build: ./backend
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
```
|
|
|
|
## Ntfy Notifications
|
|
|
|
- All web apps must integrate Ntfy push notifications for admin-relevant events
|
|
- Use a self-hosted Ntfy instance; URL and topic stored in `.env` — never hardcoded
|
|
- Send notifications via HTTP POST using `httpx` (async) — do not add a separate Ntfy library
|
|
|
|
```python
|
|
# app/utils/notify.py
|
|
import httpx
|
|
from app.config import settings
|
|
|
|
async def notify(title: str, message: str, priority: str = "default", tags: list[str] | None = None):
|
|
headers = {
|
|
"Title": title,
|
|
"Priority": priority,
|
|
}
|
|
if tags:
|
|
headers["Tags"] = ",".join(tags)
|
|
async with httpx.AsyncClient() as client:
|
|
await client.post(
|
|
f"{settings.ntfy_url}/{settings.ntfy_topic}",
|
|
content=message,
|
|
headers=headers,
|
|
)
|
|
```
|
|
|
|
**Required `.env` variables:**
|
|
```
|
|
NTFY_URL=https://ntfy.example.com
|
|
NTFY_TOPIC=your-topic
|
|
```
|
|
|
|
**Standard events to notify on** (adapt to what the app actually does):
|
|
|
|
| Event | Priority | Tags |
|
|
|---|---|---|
|
|
| Successful admin login | high | `warning` |
|
|
| Failed admin login (threshold reached) | urgent | `rotating_light` |
|
|
| New user registration | default | `busts_in_silhouette` |
|
|
| User account deletion | default | `wastebasket` |
|
|
| Role/permission escalation | high | `shield` |
|
|
| Password reset requested | default | `key` |
|
|
| Rate limit triggered | default | `no_entry` |
|
|
| API key created or revoked | high | `key` |
|
|
| Service startup / crash recovery | default | `white_check_mark` / `x` |
|
|
| High error rate (5xx spike) | high | `rotating_light` |
|
|
| Large data export initiated | default | `outbox_tray` |
|
|
|
|
**Rules:**
|
|
- Add Ntfy to every new web app from the start — it is checked during go-live review
|
|
- Only notify on events that are genuinely useful to an admin — don't spam low-value noise
|
|
- Failed notifications must not crash the app — wrap calls in `try/except` and log the error
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
- Do **not** create test files — I'll run the code directly to verify it works
|
|
- If asked to write tests, use **pytest** with **pytest-asyncio**; use a separate test DB, never the real one
|
|
|
|
## Comments
|
|
|
|
- Docstrings on all functions that aren't self-explanatory
|
|
- Comment the **why** for non-obvious logic, not the what
|