mirror of
https://github.com/fastapi/full-stack-fastapi-template.git
synced 2026-11-03 20:36:27 +00:00
Compare commits
39 Commits
6c2122e37d
...
0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 84c5a9e11a | |||
| 98fb606b57 | |||
| de2ed05b10 | |||
| ec86250a21 | |||
| 7f11815e43 | |||
| a2d2d18108 | |||
| 2d8fb3e4de | |||
| ddb3c34f19 | |||
| eae2d6701d | |||
| 6b9fa4113c | |||
| b55b3122ab | |||
| 6dbb2e8256 | |||
| 6d75b2d132 | |||
| 32ab6dd022 | |||
| cf1548a983 | |||
| c8f9069168 | |||
| 512e496e1f | |||
| fd0ca44d77 | |||
| af791805ac | |||
| 9a218cd74a | |||
| d85803bf64 | |||
| 781b283849 | |||
| c291285975 | |||
| b1abd91ba7 | |||
| e95e501951 | |||
| 111c5fad66 | |||
| 9d541082a3 | |||
| 8893c6807a | |||
| 1c5dd70308 | |||
| b5a664d510 | |||
| 23b0ba9bad | |||
| 74cf9ed832 | |||
| 4256e83fec | |||
| 85cd14d474 | |||
| 5069df32ea | |||
| f2035ef732 | |||
| b0ccf1e80e | |||
| de53c8fd35 | |||
| 375af70ee2 |
@@ -1 +0,0 @@
|
||||
{{ _copier_answers|to_json -}}
|
||||
@@ -1,37 +0,0 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
# Update the .env file with the answers from the .copier-answers.yml file
|
||||
# without using Jinja2 templates in the .env file, this way the code works as is
|
||||
# without needing Copier, but if Copier is used, the .env file will be updated
|
||||
root_path = Path(__file__).parent.parent
|
||||
answers_path = Path(__file__).parent / ".copier-answers.yml"
|
||||
answers = json.loads(answers_path.read_text())
|
||||
env_path = root_path / ".env"
|
||||
env_content = env_path.read_text()
|
||||
# Keep deployment provider settings from replacing local development defaults.
|
||||
env_answer_keys = {
|
||||
"emails_from_email",
|
||||
"first_superuser",
|
||||
"first_superuser_password",
|
||||
"postgres_password",
|
||||
"project_name",
|
||||
"secret_key",
|
||||
}
|
||||
lines = []
|
||||
for line in env_content.splitlines():
|
||||
for key, value in answers.items():
|
||||
if key not in env_answer_keys:
|
||||
continue
|
||||
upper_key = key.upper()
|
||||
if line.startswith(f"{upper_key}="):
|
||||
if " " in value:
|
||||
content = f"{upper_key}={value!r}"
|
||||
else:
|
||||
content = f"{upper_key}={value}"
|
||||
new_line = line.replace(line, content)
|
||||
lines.append(new_line)
|
||||
break
|
||||
else:
|
||||
lines.append(line)
|
||||
env_path.write_text("\n".join(lines))
|
||||
@@ -1,7 +1,6 @@
|
||||
.git
|
||||
**/__pycache__
|
||||
**/.venv
|
||||
backend/app/frontend
|
||||
backend/htmlcov
|
||||
frontend/blob-report
|
||||
frontend/dist
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Enable local-only development behavior
|
||||
ENVIRONMENT=local
|
||||
# Enable development behavior for commands that import the app directly
|
||||
FASTAPI_ENV=development
|
||||
|
||||
PROJECT_NAME="Full Stack FastAPI Project"
|
||||
|
||||
@@ -14,7 +14,5 @@ SMTP_TLS=False
|
||||
SMTP_PORT=1025
|
||||
|
||||
# Postgres
|
||||
POSTGRES_SERVER=localhost
|
||||
POSTGRES_DB=app
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=changethis
|
||||
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/app
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
!backend/app/frontend/
|
||||
@@ -1,25 +0,0 @@
|
||||
docs:
|
||||
- all:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- '**/*.md'
|
||||
- all-globs-to-all-files:
|
||||
- '!frontend/**'
|
||||
- '!backend/**'
|
||||
- '!.github/**'
|
||||
- '!scripts/**'
|
||||
- '!.gitignore'
|
||||
- '!.pre-commit-config.yaml'
|
||||
|
||||
internal:
|
||||
- all:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/**
|
||||
- scripts/**
|
||||
- .gitignore
|
||||
- .pre-commit-config.yaml
|
||||
- all-globs-to-all-files:
|
||||
- '!./**/*.md'
|
||||
- '!frontend/**'
|
||||
- '!backend/**'
|
||||
@@ -0,0 +1,12 @@
|
||||
auto-labels:
|
||||
docs:
|
||||
- exclude: frontend/**
|
||||
- exclude: backend/**
|
||||
- exclude: .github/**
|
||||
- exclude: scripts/**
|
||||
- '**/*.md'
|
||||
internal:
|
||||
- .github/**
|
||||
- scripts/**
|
||||
- .gitignore
|
||||
- .pre-commit-config.yaml
|
||||
@@ -1,2 +1,3 @@
|
||||
workflows:
|
||||
- .github/workflows/bump-pre-commit-hooks.yml
|
||||
- .github/workflows/prepare-release.yml
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Create Draft Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
create-draft-release:
|
||||
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Dump GitHub context
|
||||
env:
|
||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||
run: echo "$GITHUB_CONTEXT"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||
version: "0.11.18"
|
||||
- name: Extract release details
|
||||
id: release-details
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(uv run python scripts/prepare_release.py current-version)"
|
||||
uv run python scripts/prepare_release.py release-notes > draft-release-notes.md
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Create draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.release-details.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release create "$VERSION" \
|
||||
--draft \
|
||||
--title "$VERSION" \
|
||||
--notes-file draft-release-notes.md \
|
||||
--target "$(git rev-parse HEAD)"
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Deploy with Docker Compose
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-docker-compose
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Do not deploy the template repository, only projects created from it
|
||||
if: github.repository != 'fastapi/full-stack-fastapi-template'
|
||||
runs-on: self-hosted
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DOMAIN: ${{ vars.DOMAIN }}
|
||||
PROJECT_NAME: ${{ vars.PROJECT_NAME }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
FIRST_SUPERUSER: ${{ vars.FIRST_SUPERUSER }}
|
||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||
SMTP_HOST: ${{ vars.SMTP_HOST }}
|
||||
SMTP_USER: ${{ vars.SMTP_USER }}
|
||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||
EMAILS_FROM_EMAIL: ${{ vars.EMAILS_FROM_EMAIL }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build
|
||||
run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||
- name: Prepare database
|
||||
run: docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||
- name: Start application
|
||||
run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment: production
|
||||
# Do not deploy in the main repository, only in user projects
|
||||
if: github.repository_owner != 'fastapi'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- production
|
||||
env:
|
||||
DOMAIN: ${{ secrets.DOMAIN }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Deploy to Staging
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment: staging
|
||||
# Do not deploy in the main repository, only in user projects
|
||||
if: github.repository_owner != 'fastapi'
|
||||
runs-on:
|
||||
- self-hosted
|
||||
- staging
|
||||
env:
|
||||
DOMAIN: ${{ secrets.DOMAIN }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- release-notes.md
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Do not deploy the template repository, only projects created from it
|
||||
if: github.repository != 'fastapi/full-stack-fastapi-template'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.12
|
||||
- name: Install frontend dependencies
|
||||
run: bun ci
|
||||
- name: Build frontend
|
||||
run: bun run --filter frontend build
|
||||
env:
|
||||
# Override frontend/.env so the deployed frontend uses the same origin as the API
|
||||
VITE_API_URL: ""
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: "0.11.18"
|
||||
- name: Prepare database
|
||||
run: uv run bash scripts/prestart.sh
|
||||
working-directory: backend
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
PROJECT_NAME: ${{ vars.PROJECT_NAME }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
FIRST_SUPERUSER: ${{ vars.FIRST_SUPERUSER }}
|
||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||
- name: Deploy to FastAPI Cloud
|
||||
run: uv run fastapi deploy
|
||||
env:
|
||||
FASTAPI_CLOUD_TOKEN: ${{ secrets.FASTAPI_CLOUD_TOKEN }}
|
||||
FASTAPI_CLOUD_APP_ID: ${{ secrets.FASTAPI_CLOUD_APP_ID }}
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Labels
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
# For label-checker
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
|
||||
- run: echo "Done adding labels"
|
||||
# Run this after labeler applied labels
|
||||
check-labels:
|
||||
needs:
|
||||
- labeler
|
||||
permissions:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
|
||||
with:
|
||||
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -78,6 +78,7 @@ jobs:
|
||||
- run: bash scripts/generate-client.sh
|
||||
- run: docker compose build
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- run: docker compose run --rm backend bash scripts/prestart.sh
|
||||
- name: Run Playwright tests
|
||||
run: docker compose run --rm playwright bunx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
- run: docker compose down -v --remove-orphans
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Prepare Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
description: Release bump
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
date:
|
||||
description: Release date in YYYY-MM-DD format. Defaults to today.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
prepare-release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Dump GitHub context
|
||||
env:
|
||||
GITHUB_CONTEXT: ${{ toJson(github) }}
|
||||
run: echo "$GITHUB_CONTEXT"
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
# Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum.
|
||||
# See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837
|
||||
version: "0.11.18"
|
||||
- name: Prepare release
|
||||
env:
|
||||
BUMP: ${{ inputs.bump }}
|
||||
RELEASE_DATE: ${{ inputs.date }}
|
||||
run: uv run python scripts/prepare_release.py prepare "$BUMP" --date "$RELEASE_DATE"
|
||||
- name: Get release version
|
||||
id: release-version
|
||||
run: |
|
||||
version="$(uv run python scripts/prepare_release.py current-version)"
|
||||
echo "$version"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
- name: Get PR Submit token
|
||||
id: pr-submit
|
||||
uses: tiangolo/pr-submit@d802fdf59bde80bc3eb8bd3259f4cbeec63de4aa # 0.0.1
|
||||
- name: Create release pull request
|
||||
env:
|
||||
BASE_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
GH_TOKEN: ${{ steps.pr-submit.outputs.token }}
|
||||
VERSION: ${{ steps.release-version.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
branch="release-${VERSION}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
git config user.name "pr-submit[bot]"
|
||||
git config user.email "pr-submit[bot]@users.noreply.github.com"
|
||||
git switch -c "$branch"
|
||||
git add release-notes.md
|
||||
git commit -m "🔖 Release version ${VERSION}"
|
||||
gh auth setup-git
|
||||
git push --set-upstream origin "$branch"
|
||||
gh label create release \
|
||||
--description "Release preparation" \
|
||||
--color ededed \
|
||||
--force
|
||||
gh pr create \
|
||||
--base "$BASE_BRANCH" \
|
||||
--head "$branch" \
|
||||
--title "🔖 Release version ${VERSION}" \
|
||||
--body "Prepare release ${VERSION}." \
|
||||
--label release
|
||||
@@ -20,6 +20,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
- run: docker compose build
|
||||
- run: docker compose down -v --remove-orphans
|
||||
- run: docker compose run --rm backend bash scripts/prestart.sh
|
||||
- run: docker compose up -d --wait backend adminer
|
||||
- name: Test backend is up
|
||||
run: curl http://localhost:8000/api/v1/utils/health-check
|
||||
|
||||
@@ -13,7 +13,7 @@ repos:
|
||||
exclude: |
|
||||
(?x)^(
|
||||
frontend/src/client/.*|
|
||||
backend/app/email-templates/build/.*
|
||||
backend/app/email-templates/.*
|
||||
)$
|
||||
- id: trailing-whitespace
|
||||
exclude: ^frontend/src/client/.*
|
||||
|
||||
Vendored
-1
@@ -7,7 +7,6 @@
|
||||
"charliermarsh.ruff",
|
||||
"docker.docker",
|
||||
"github.vscode-github-actions",
|
||||
"mjmlio.vscode-mjml",
|
||||
"ms-playwright.playwright",
|
||||
"ms-python.python",
|
||||
"tombi-toml.tombi"
|
||||
|
||||
@@ -11,20 +11,21 @@
|
||||
- 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
|
||||
- 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database.
|
||||
- 🚀 [React](https://react.dev) for the frontend.
|
||||
- 🧩 Built into the backend image and served by FastAPI on the same domain as the API.
|
||||
- 🧩 Built into the backend application and served by FastAPI on the same domain as the API.
|
||||
- 💃 Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack.
|
||||
- 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components.
|
||||
- 🤖 An automatically generated frontend client.
|
||||
- 🧪 [Playwright](https://playwright.dev) for End-to-End testing.
|
||||
- 🦇 Dark mode support.
|
||||
- 🐋 [Docker Compose](https://www.docker.com) for local services and deployment.
|
||||
- ☁️ [FastAPI Cloud](https://fastapicloud.com) for deployment.
|
||||
- 🐋 [Docker Compose](https://www.docker.com) for local services and self-hosted deployment.
|
||||
- 📞 [Traefik](https://traefik.io) as a reverse proxy with automatic HTTPS.
|
||||
- 🔒 Secure password hashing by default.
|
||||
- 🔑 JWT (JSON Web Token) authentication.
|
||||
- 📫 Email based password recovery.
|
||||
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
||||
- ✅ Tests with [Pytest](https://pytest.org).
|
||||
- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer.
|
||||
- 🚢 Deployment instructions using Docker Compose with automatic HTTPS provided by Traefik.
|
||||
- 🚢 Deployment with FastAPI Cloud or self-hosting with Docker Compose and Traefik.
|
||||
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
||||
|
||||
### Dashboard Login
|
||||
@@ -123,17 +124,7 @@ git merge --continue
|
||||
|
||||
### Configure
|
||||
|
||||
You can then update configs in the `.env` files to customize your configurations.
|
||||
|
||||
Before deploying it, make sure you change at least the values for:
|
||||
|
||||
- `SECRET_KEY`
|
||||
- `FIRST_SUPERUSER_PASSWORD`
|
||||
- `POSTGRES_PASSWORD`
|
||||
|
||||
You can (and should) pass these as environment variables from secrets.
|
||||
|
||||
Read the [deployment.md](./deployment.md) docs for more details.
|
||||
You can update the local settings in the `.env` file. Before deploying, configure the environment variables and secrets described in the [FastAPI Cloud deployment guide](./deployment.md) or the [Docker Compose deployment guide](./deployment-docker-compose.md). Never deploy with the default `changethis` values.
|
||||
|
||||
### Generate Secret Keys
|
||||
|
||||
@@ -147,61 +138,6 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
|
||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
||||
|
||||
## How To Use It - Alternative With Copier
|
||||
|
||||
This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
|
||||
|
||||
It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
|
||||
|
||||
### Install Copier
|
||||
|
||||
You can install Copier with:
|
||||
|
||||
```bash
|
||||
pip install copier
|
||||
```
|
||||
|
||||
Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
|
||||
|
||||
```bash
|
||||
pipx install copier
|
||||
```
|
||||
|
||||
**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
|
||||
|
||||
### Generate a Project With Copier
|
||||
|
||||
Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
|
||||
|
||||
Go to the directory that will be the parent of your project, and run the command with your project's name:
|
||||
|
||||
```bash
|
||||
copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
||||
```
|
||||
|
||||
If you have `pipx` and you didn't install `copier`, you can run it directly:
|
||||
|
||||
```bash
|
||||
pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
||||
```
|
||||
|
||||
**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
|
||||
|
||||
### Input Variables
|
||||
|
||||
Copier will ask you for some data, you might want to have at hand before generating the project.
|
||||
|
||||
But don't worry, you can just update any of that in the `.env` files afterwards.
|
||||
|
||||
The input variables, with their default values (some auto generated) are:
|
||||
|
||||
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
|
||||
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
|
||||
- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
|
||||
- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
|
||||
- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
|
||||
- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
|
||||
|
||||
## Backend Development
|
||||
|
||||
Backend docs: [backend/README.md](./backend/README.md).
|
||||
@@ -214,11 +150,13 @@ Frontend docs: [frontend/README.md](./frontend/README.md).
|
||||
|
||||
Deployment docs: [deployment.md](./deployment.md).
|
||||
|
||||
Docker Compose deployment: [deployment-docker-compose.md](./deployment-docker-compose.md).
|
||||
|
||||
## Development
|
||||
|
||||
General development docs: [development.md](./development.md).
|
||||
|
||||
This includes the local FastAPI and Vite workflow, Docker Compose services, custom local domains, `.env` configuration, and more.
|
||||
This includes the local FastAPI and Vite workflow, Docker Compose services, `.env` configuration, and more.
|
||||
|
||||
## Release Notes
|
||||
|
||||
|
||||
+17
-18
@@ -27,21 +27,7 @@ The API is available at `http://localhost:8000`, with automatic interactive docs
|
||||
|
||||
## General Workflow
|
||||
|
||||
By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it.
|
||||
|
||||
From `./backend/` you can install all the dependencies with:
|
||||
|
||||
```console
|
||||
$ uv sync
|
||||
```
|
||||
|
||||
Then you can activate the virtual environment with:
|
||||
|
||||
```console
|
||||
$ source .venv/bin/activate
|
||||
```
|
||||
|
||||
Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`.
|
||||
Run backend commands from `./backend/` with `uv run`. Make sure your editor uses the Python interpreter at `.venv/bin/python` in the project root.
|
||||
|
||||
Modify or add SQLModel models for data and SQL tables in `./backend/app/models.py`, API endpoints in `./backend/app/api/`, CRUD (Create, Read, Update, Delete) utils in `./backend/app/crud.py`.
|
||||
|
||||
@@ -56,6 +42,7 @@ The setup is also already configured so you can run the tests through the VS Cod
|
||||
To run the backend and built frontend in Docker Compose:
|
||||
|
||||
```console
|
||||
$ docker compose run --rm backend bash scripts/prestart.sh
|
||||
$ docker compose watch
|
||||
```
|
||||
|
||||
@@ -139,8 +126,20 @@ If you don't want to start with the default models and want to remove them / mod
|
||||
|
||||
## Email Templates
|
||||
|
||||
The email templates are in `./backend/app/email-templates/`. Here, there are two directories: `build` and `src`. The `src` directory contains the source files that are used to build the final email templates. The `build` directory contains the final email templates that are used by the application.
|
||||
The email templates are written with [React Email](https://react.email) in `./packages/react-email/`. The `emails` directory holds one component per email and the `ui` directory holds the shared components (layout, heading, button, link, callout).
|
||||
|
||||
Before continuing, ensure you have the [MJML extension](https://github.com/mjmlio/vscode-mjml) installed in your VS Code.
|
||||
The rendered HTML in `./backend/app/email-templates/` is generated from those components, it is what the application sends, and it shouldn't be edited by hand.
|
||||
|
||||
Once you have the MJML extension installed, you can create a new email template in the `src` directory. After creating the new email template and with the `.mjml` file open in your editor, open the command palette with `Ctrl+Shift+P` and search for `MJML: Export to HTML`. This will convert the `.mjml` file to a `.html` file and now you can save it in the build directory.
|
||||
To preview the emails while editing them, start the dev server from the root of the project:
|
||||
|
||||
```console
|
||||
$ bun run email:dev
|
||||
```
|
||||
|
||||
Values coming from the backend are declared as Jinja placeholders in the component props, for example `username = "{{ username }}"`. The context for each email is built in `generate_*_email()` in `./backend/app/utils.py`, so a new placeholder needs to be added there too.
|
||||
|
||||
Once you are done, regenerate the templates used by the application:
|
||||
|
||||
```console
|
||||
$ bun run email:export
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
def get_url():
|
||||
return str(settings.SQLALCHEMY_DATABASE_URI)
|
||||
return str(settings.DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
|
||||
@@ -10,5 +10,5 @@ api_router.include_router(utils.router)
|
||||
api_router.include_router(items.router)
|
||||
|
||||
|
||||
if settings.ENVIRONMENT == "local":
|
||||
if settings.FASTAPI_ENV == "development":
|
||||
api_router.include_router(private.router)
|
||||
|
||||
+16
-43
@@ -1,27 +1,17 @@
|
||||
import secrets
|
||||
import warnings
|
||||
from typing import Annotated, Any, Literal, Self
|
||||
from typing import Literal, Self
|
||||
|
||||
from pydantic import (
|
||||
AnyUrl,
|
||||
BeforeValidator,
|
||||
EmailStr,
|
||||
HttpUrl,
|
||||
PostgresDsn,
|
||||
computed_field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def parse_cors(v: Any) -> list[str] | str:
|
||||
if isinstance(v, str) and not v.startswith("["):
|
||||
return [i.strip() for i in v.split(",") if i.strip()]
|
||||
elif isinstance(v, list | str):
|
||||
return v
|
||||
raise ValueError(v)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
# Use top level .env file (one level above ./backend/)
|
||||
@@ -30,42 +20,24 @@ class Settings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
API_V1_STR: str = "/api/v1"
|
||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||
SECRET_KEY: str
|
||||
# 60 minutes * 24 hours * 8 days = 8 days
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||
FRONTEND_HOST: str = "http://localhost:5173"
|
||||
ENVIRONMENT: Literal["local"] | None = None
|
||||
|
||||
BACKEND_CORS_ORIGINS: Annotated[
|
||||
list[AnyUrl] | str, BeforeValidator(parse_cors)
|
||||
] = []
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def all_cors_origins(self) -> list[str]:
|
||||
return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [
|
||||
self.FRONTEND_HOST
|
||||
]
|
||||
FASTAPI_ENV: Literal["development"] | None = None
|
||||
|
||||
PROJECT_NAME: str
|
||||
SENTRY_DSN: HttpUrl | None = None
|
||||
POSTGRES_SERVER: str
|
||||
POSTGRES_PORT: int = 5432
|
||||
POSTGRES_USER: str
|
||||
POSTGRES_PASSWORD: str = ""
|
||||
POSTGRES_DB: str = ""
|
||||
DATABASE_URL: PostgresDsn
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
|
||||
return PostgresDsn.build(
|
||||
scheme="postgresql+psycopg",
|
||||
username=self.POSTGRES_USER,
|
||||
password=self.POSTGRES_PASSWORD,
|
||||
host=self.POSTGRES_SERVER,
|
||||
port=self.POSTGRES_PORT,
|
||||
path=self.POSTGRES_DB,
|
||||
)
|
||||
@field_validator("DATABASE_URL", mode="before")
|
||||
@classmethod
|
||||
def _use_psycopg_driver(cls, value: str | PostgresDsn) -> str:
|
||||
database_url = str(value)
|
||||
for scheme in ("postgres://", "postgresql://"):
|
||||
if database_url.startswith(scheme):
|
||||
return database_url.replace(scheme, "postgresql+psycopg://", 1)
|
||||
return database_url
|
||||
|
||||
SMTP_TLS: bool = True
|
||||
SMTP_SSL: bool = False
|
||||
@@ -99,7 +71,7 @@ class Settings(BaseSettings):
|
||||
f'The value of {var_name} is "changethis", '
|
||||
"for security, please change it, at least for deployments."
|
||||
)
|
||||
if self.ENVIRONMENT == "local":
|
||||
if self.FASTAPI_ENV == "development":
|
||||
warnings.warn(message, stacklevel=1)
|
||||
else:
|
||||
raise ValueError(message)
|
||||
@@ -107,7 +79,8 @@ class Settings(BaseSettings):
|
||||
@model_validator(mode="after")
|
||||
def _enforce_non_default_secrets(self) -> Self:
|
||||
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
|
||||
self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD)
|
||||
for host in self.DATABASE_URL.hosts():
|
||||
self._check_default_secret("DATABASE_URL password", host["password"])
|
||||
self._check_default_secret(
|
||||
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from app import crud
|
||||
from app.core.config import settings
|
||||
from app.models import User, UserCreate
|
||||
|
||||
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
|
||||
engine = create_engine(str(settings.DATABASE_URL))
|
||||
|
||||
|
||||
# make sure all SQLModel models are imported (app.models) before initializing DB
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
||||
.ReadMsgBody { width:100%; }
|
||||
.ExternalClass { width:100%; }
|
||||
.ExternalClass * { line-height:100%; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
||||
@-ms-viewport { width:320px; }
|
||||
@viewport { width:320px; }
|
||||
}</style><!--<![endif]--><!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]--><!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }} - New Account</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Welcome to your new account!</span></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Here are your account details:</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Username: {{ username }}</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Password: {{ password }}</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:15px 30px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#009688" role="presentation" style="border:none;border-radius:8px;cursor:auto;padding:10px 25px;background:#009688;" valign="middle"><a href="{{ link }}" style="background:#009688;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Go to Dashboard</a></td></tr></table></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]--></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
||||
@@ -1,25 +0,0 @@
|
||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
||||
.ReadMsgBody { width:100%; }
|
||||
.ExternalClass { width:100%; }
|
||||
.ExternalClass * { line-height:100%; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
||||
@-ms-viewport { width:320px; }
|
||||
@viewport { width:320px; }
|
||||
}</style><!--<![endif]--><!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]--><!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]--><!--[if !mso]><!--><link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css"><style type="text/css">@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);</style><!--<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }} - Password Recovery</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Hello {{ username }}</span></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">We've received a request to reset your password. You can do it by clicking the button below:</div></td></tr><tr><td align="center" vertical-align="middle" style="font-size:0px;padding:15px 30px;word-break:break-word;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;"><tr><td align="center" bgcolor="#009688" role="presentation" style="border:none;border-radius:8px;cursor:auto;padding:10px 25px;background:#009688;" valign="middle"><a href="{{ link }}" style="background:#009688;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;font-weight:normal;line-height:120%;Margin:0;text-decoration:none;text-transform:none;" target="_blank">Reset password</a></td></tr></table></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">Or copy and paste the following link into your browser:</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><a href="{{ link }}">{{ link }}</a></div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;">This password will expire in {{ valid_hours }} hours.</div></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]--></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:1;text-align:center;color:#555555;">If you didn't request a password recovery you can disregard this email.</div></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
||||
@@ -1,25 +0,0 @@
|
||||
<!doctype html><html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><head><title></title><!--[if !mso]><!-- --><meta http-equiv="X-UA-Compatible" content="IE=edge"><!--<![endif]--><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style type="text/css">#outlook a { padding:0; }
|
||||
.ReadMsgBody { width:100%; }
|
||||
.ExternalClass { width:100%; }
|
||||
.ExternalClass * { line-height:100%; }
|
||||
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
|
||||
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
|
||||
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
|
||||
p { display:block;margin:13px 0; }</style><!--[if !mso]><!--><style type="text/css">@media only screen and (max-width:480px) {
|
||||
@-ms-viewport { width:320px; }
|
||||
@viewport { width:320px; }
|
||||
}</style><!--<![endif]--><!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]--><!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]--><style type="text/css">@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 { width:100% !important; max-width: 100%; }
|
||||
}</style><style type="text/css"></style></head><body style="background-color:#fafbfc;"><div style="background-color:#fafbfc;"><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]--><div style="background:#ffffff;background-color:#ffffff;Margin:0px auto;max-width:600px;"><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;"><tbody><tr><td style="direction:ltr;font-size:0px;padding:40px 20px;text-align:center;vertical-align:top;"><!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:560px;" ><![endif]--><div class="mj-column-per-100 outlook-group-fix" style="font-size:13px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;"><table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%"><tr><td align="center" style="font-size:0px;padding:35px;word-break:break-word;"><div style="font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:1;text-align:center;color:#333333;">{{ project_name }}</div></td></tr><tr><td align="center" style="font-size:0px;padding:10px 25px;padding-right:25px;padding-left:25px;word-break:break-word;"><div style="font-family:, sans-serif;font-size:16px;line-height:1;text-align:center;color:#555555;"><span>Test email for: {{ email }}</span></div></td></tr><tr><td style="font-size:0px;padding:10px 25px;word-break:break-word;"><p style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:100%;"></p><!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #cccccc;font-size:1;margin:0px auto;width:510px;" role="presentation" width="510px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]--></td></tr></table></div><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table></div><!--[if mso | IE]></td></tr></table><![endif]--></div></body></html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
||||
<mjml>
|
||||
<mj-body background-color="#fafbfc">
|
||||
<mj-section background-color="#fff" padding="40px 20px">
|
||||
<mj-column vertical-align="middle" width="100%">
|
||||
<mj-text align="center" padding="35px" font-size="20px" color="#333">{{ project_name }} - New Account</mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><span>Welcome to your new account!</span></mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Here are your account details:</mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Username: {{ username }}</mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Password: {{ password }}</mj-text>
|
||||
<mj-button align="center" font-size="18px" background-color="#009688" border-radius="8px" color="#fff" href="{{ link }}" padding="15px 30px">Go to Dashboard</mj-button>
|
||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,17 +0,0 @@
|
||||
<mjml>
|
||||
<mj-body background-color="#fafbfc">
|
||||
<mj-section background-color="#fff" padding="40px 20px">
|
||||
<mj-column vertical-align="middle" width="100%">
|
||||
<mj-text align="center" padding="35px" font-size="20px" font-family="Arial, Helvetica, sans-serif" color="#333">{{ project_name }} - Password Recovery</mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><span>Hello {{ username }}</span></mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">We've received a request to reset your password. You can do it by clicking the button below:</mj-text>
|
||||
<mj-button align="center" font-size="18px" background-color="#009688" border-radius="8px" color="#fff" href="{{ link }}" padding="15px 30px">Reset password</mj-button>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">Or copy and paste the following link into your browser:</mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555"><a href="{{ link }}">{{ link }}</a></mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">This password will expire in {{ valid_hours }} hours.</mj-text>
|
||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
||||
<mj-text align="center" font-size="14px" padding-left="25px" padding-right="25px" font-family="Arial, Helvetica, sans-serif" color="#555">If you didn't request a password recovery you can disregard this email.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -1,11 +0,0 @@
|
||||
<mjml>
|
||||
<mj-body background-color="#fafbfc">
|
||||
<mj-section background-color="#fff" padding="40px 20px">
|
||||
<mj-column vertical-align="middle" width="100%">
|
||||
<mj-text align="center" padding="35px" font-size="20px" font-family="Arial, Helvetica, sans-serif" color="#333">{{ project_name }}</mj-text>
|
||||
<mj-text align="center" font-size="16px" padding-left="25px" padding-right="25px" font-family=", sans-serif" color="#555"><span>Test email for: {{ email }}</span></mj-text>
|
||||
<mj-divider border-color="#ccc" border-width="2px"></mj-divider>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
File diff suppressed because one or more lines are too long
+4
-6
@@ -15,7 +15,7 @@ def custom_generate_unique_id(route: APIRoute) -> str:
|
||||
return f"{route.tags[0]}-{route.name}"
|
||||
|
||||
|
||||
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
|
||||
if settings.SENTRY_DSN and settings.FASTAPI_ENV != "development":
|
||||
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
|
||||
|
||||
app = FastAPI(
|
||||
@@ -24,15 +24,13 @@ app = FastAPI(
|
||||
generate_unique_id_function=custom_generate_unique_id,
|
||||
)
|
||||
|
||||
# Set all CORS enabled origins
|
||||
if settings.all_cors_origins:
|
||||
app.add_middleware(
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.all_cors_origins,
|
||||
allow_origins=[settings.FRONTEND_HOST],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||
app.frontend("/", directory=FRONTEND_DIR)
|
||||
|
||||
@@ -24,7 +24,7 @@ class EmailData:
|
||||
|
||||
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
||||
template_str = (
|
||||
Path(__file__).parent / "email-templates" / "build" / template_name
|
||||
Path(__file__).parent / "email-templates" / template_name
|
||||
).read_text()
|
||||
html_content = Template(template_str).render(context)
|
||||
return html_content
|
||||
|
||||
@@ -44,10 +44,6 @@ services:
|
||||
# Use the Let's Encrypt resolver
|
||||
- traefik.http.routers.adminer-https.tls.certresolver=le
|
||||
|
||||
prestart:
|
||||
environment:
|
||||
FRONTEND_HOST: https://${DOMAIN:?Variable not set}
|
||||
|
||||
backend:
|
||||
restart: always
|
||||
environment:
|
||||
|
||||
+4
-13
@@ -1,9 +1,5 @@
|
||||
services:
|
||||
|
||||
# Local services are available on their ports, but also available on:
|
||||
# http://localhost.tiangolo.com: application
|
||||
# etc. To enable it, update .env, set:
|
||||
# DOMAIN=localhost.tiangolo.com
|
||||
proxy:
|
||||
image: traefik:3.6
|
||||
ports:
|
||||
@@ -44,16 +40,15 @@ services:
|
||||
# command: sleep infinity # Infinite loop to keep container alive doing nothing
|
||||
command:
|
||||
- fastapi
|
||||
- run
|
||||
- --reload
|
||||
- "app/main.py"
|
||||
- dev
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
develop:
|
||||
watch:
|
||||
- path: ./backend
|
||||
action: sync
|
||||
target: /app/backend
|
||||
ignore:
|
||||
- ./backend/.venv
|
||||
- .venv
|
||||
- path: ./backend/pyproject.toml
|
||||
action: rebuild
|
||||
@@ -68,15 +63,11 @@ services:
|
||||
volumes:
|
||||
- ./backend/htmlcov:/app/backend/htmlcov
|
||||
environment:
|
||||
ENVIRONMENT: "local"
|
||||
FASTAPI_ENV: "development"
|
||||
SMTP_HOST: "mailcatcher"
|
||||
SMTP_PORT: "1025"
|
||||
SMTP_TLS: "false"
|
||||
|
||||
prestart:
|
||||
environment:
|
||||
ENVIRONMENT: "local"
|
||||
|
||||
mailcatcher:
|
||||
image: schickling/mailcatcher
|
||||
ports:
|
||||
|
||||
+4
-24
@@ -20,7 +20,7 @@ services:
|
||||
db:
|
||||
image: postgres:18
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
@@ -29,8 +29,7 @@ services:
|
||||
- app-db-data:/var/lib/postgresql
|
||||
environment:
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Variable not set}
|
||||
- POSTGRES_USER=${POSTGRES_USER:?Variable not set}
|
||||
- POSTGRES_DB=${POSTGRES_DB:?Variable not set}
|
||||
- POSTGRES_DB=app
|
||||
|
||||
adminer:
|
||||
image: adminer
|
||||
@@ -53,12 +52,8 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
prestart:
|
||||
condition: service_completed_successfully
|
||||
# The YAML anchor lets the prestart service reuse this environment mapping.
|
||||
environment: &backend-environment
|
||||
environment:
|
||||
PROJECT_NAME: ${PROJECT_NAME:?Variable not set}
|
||||
BACKEND_CORS_ORIGINS: ${BACKEND_CORS_ORIGINS:-}
|
||||
SECRET_KEY: ${SECRET_KEY:?Variable not set}
|
||||
FIRST_SUPERUSER: ${FIRST_SUPERUSER:?Variable not set}
|
||||
FIRST_SUPERUSER_PASSWORD: ${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||
@@ -66,10 +61,7 @@ services:
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
EMAILS_FROM_EMAIL: ${EMAILS_FROM_EMAIL}
|
||||
POSTGRES_SERVER: db
|
||||
POSTGRES_DB: ${POSTGRES_DB:?Variable not set}
|
||||
POSTGRES_USER: ${POSTGRES_USER:?Variable not set}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Variable not set}
|
||||
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:?Variable not set}@db:5432/app
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
|
||||
healthcheck:
|
||||
@@ -92,17 +84,5 @@ services:
|
||||
- traefik.http.routers.backend-http.rule=Host(`${DOMAIN:-localhost}`)
|
||||
- traefik.http.routers.backend-http.entrypoints=http
|
||||
|
||||
prestart:
|
||||
image: backend:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
command: bash scripts/prestart.sh
|
||||
environment: *backend-environment
|
||||
|
||||
volumes:
|
||||
app-db-data:
|
||||
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
project_name:
|
||||
type: str
|
||||
help: The name of the project, shown to API users (in .env)
|
||||
default: FastAPI Project
|
||||
|
||||
secret_key:
|
||||
type: str
|
||||
help: |
|
||||
'The secret key for the project, used for security,
|
||||
stored in .env, you can generate one with:
|
||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||
default: changethis
|
||||
|
||||
first_superuser:
|
||||
type: str
|
||||
help: The email of the first superuser (in .env)
|
||||
default: admin@example.com
|
||||
|
||||
first_superuser_password:
|
||||
type: str
|
||||
help: The password of the first superuser (in .env)
|
||||
default: changethis
|
||||
|
||||
emails_from_email:
|
||||
type: str
|
||||
help: The email account to send emails from, you can set it later in .env
|
||||
default: info@example.com
|
||||
|
||||
postgres_password:
|
||||
type: str
|
||||
help: |
|
||||
'The password for the PostgreSQL database, stored in .env,
|
||||
you can generate one with:
|
||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||
default: changethis
|
||||
|
||||
_exclude:
|
||||
# Global
|
||||
- .vscode
|
||||
- .mypy_cache
|
||||
# Python
|
||||
- __pycache__
|
||||
- app.egg-info
|
||||
- "*.pyc"
|
||||
- .mypy_cache
|
||||
- .coverage
|
||||
- htmlcov
|
||||
- .cache
|
||||
- .venv
|
||||
# Frontend
|
||||
# Logs
|
||||
- logs
|
||||
- "*.log"
|
||||
- npm-debug.log*
|
||||
- yarn-debug.log*
|
||||
- yarn-error.log*
|
||||
- pnpm-debug.log*
|
||||
- lerna-debug.log*
|
||||
- node_modules
|
||||
- dist
|
||||
- dist-ssr
|
||||
- "*.local"
|
||||
# Editor directories and files
|
||||
- .idea
|
||||
- .DS_Store
|
||||
- "*.suo"
|
||||
- "*.ntvs*"
|
||||
- "*.njsproj"
|
||||
- "*.sln"
|
||||
- "*.sw?"
|
||||
|
||||
_answers_file: .copier/.copier-answers.yml
|
||||
|
||||
_tasks:
|
||||
- ["{{ _copier_python }}", .copier/update_dotenv.py]
|
||||
@@ -0,0 +1,129 @@
|
||||
# FastAPI Project - Docker Compose Deployment
|
||||
|
||||
You can deploy the project to your own remote server with Docker Compose. The deployment configuration includes Traefik to handle HTTPS and route incoming traffic to the application.
|
||||
|
||||
## Preparation
|
||||
|
||||
* Have a remote server ready and available.
|
||||
* Configure DNS records pointing to the server for the application domain and any supporting service subdomains you want to expose, such as `fastapi-project.example.com` and `adminer.fastapi-project.example.com`.
|
||||
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
||||
|
||||
## Copy the Code
|
||||
|
||||
```bash
|
||||
rsync -av --exclude=".git/" --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
||||
```
|
||||
|
||||
The `--filter=":- .gitignore"` option tells `rsync` to use the same ignore rules as Git, excluding files such as the Python virtual environment.
|
||||
|
||||
## Configure the Application
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set the application domain, project name, and first superuser email:
|
||||
|
||||
```bash
|
||||
export DOMAIN=fastapi-project.example.com
|
||||
export PROJECT_NAME="Full Stack FastAPI Project"
|
||||
export FIRST_SUPERUSER=admin@example.com
|
||||
```
|
||||
|
||||
You can also configure these environment variables as needed:
|
||||
|
||||
* `SMTP_HOST`: The SMTP server host from your email provider.
|
||||
* `SMTP_USER`: The SMTP server user.
|
||||
* `EMAILS_FROM_EMAIL`: The email account used to send emails.
|
||||
* `SENTRY_DSN`: The DSN for Sentry.
|
||||
|
||||
### Secrets
|
||||
|
||||
Generate and set secure values for the database password, token signing key, and first superuser password:
|
||||
|
||||
```bash
|
||||
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
export FIRST_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
```
|
||||
|
||||
To use an authenticated email provider, also set `SMTP_PASSWORD`.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cd /root/code/app/
|
||||
docker compose -f compose.yml -f compose.deploy.yml build
|
||||
docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||
docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||
```
|
||||
|
||||
The `compose.deploy.yml` file adds HTTPS and automatic certificate handling to the shared `compose.yml` configuration. Explicitly listing both files excludes the local settings from `compose.override.yml`.
|
||||
|
||||
The backend Docker image builds the frontend, so the server does not need Bun or prebuilt frontend files.
|
||||
|
||||
## Deploy with GitHub Actions
|
||||
|
||||
The included `.github/workflows/deploy-docker-compose.yml` workflow runs the deployment commands on the server when manually triggered from GitHub Actions.
|
||||
|
||||
Use a self-hosted runner only for a repository whose contributors and workflow code you trust. GitHub recommends using self-hosted runners with private repositories because workflows execute directly on the runner machine.
|
||||
|
||||
### Configure Repository Variables and Secrets
|
||||
|
||||
In the repository, go to **Settings** > **Secrets and variables** > **Actions** and add these repository variables:
|
||||
|
||||
* `DOMAIN`
|
||||
* `PROJECT_NAME`
|
||||
* `FIRST_SUPERUSER`
|
||||
|
||||
To enable emails, add these optional repository variables:
|
||||
|
||||
* `SMTP_HOST`
|
||||
* `SMTP_USER`
|
||||
* `EMAILS_FROM_EMAIL`
|
||||
|
||||
To enable Sentry, add the optional `SENTRY_DSN` repository variable.
|
||||
|
||||
Add these repository secrets:
|
||||
|
||||
* `POSTGRES_PASSWORD`
|
||||
* `SECRET_KEY`
|
||||
* `FIRST_SUPERUSER_PASSWORD`
|
||||
|
||||
To use an authenticated email provider, add the optional `SMTP_PASSWORD` repository secret.
|
||||
|
||||
### Install a Self-Hosted Runner
|
||||
|
||||
On the server, create a dedicated user and grant it access to Docker:
|
||||
|
||||
```bash
|
||||
sudo adduser github
|
||||
sudo usermod -aG docker github
|
||||
sudo su - github
|
||||
```
|
||||
|
||||
In the GitHub repository, go to **Settings** > **Actions** > **Runners**, select **New self-hosted runner**, choose Linux, and follow the commands GitHub provides to download, configure, and register the runner. Install it in `/home/github/actions-runner`.
|
||||
|
||||
After registering the runner, exit the `github` user session and install the runner as a system service:
|
||||
|
||||
```bash
|
||||
exit
|
||||
cd /home/github/actions-runner
|
||||
sudo ./svc.sh install github
|
||||
sudo ./svc.sh start
|
||||
sudo ./svc.sh status
|
||||
```
|
||||
|
||||
See GitHub's guides for [adding a self-hosted runner](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners) and [configuring the runner as a service](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application?platform=linux).
|
||||
|
||||
### Run the Deployment
|
||||
|
||||
When the runner is online, open the repository's **Actions** tab, select **Deploy with Docker Compose**, and select **Run workflow**.
|
||||
|
||||
## URLs
|
||||
|
||||
Replace `fastapi-project.example.com` with your domain.
|
||||
|
||||
Application (frontend and API): `https://fastapi-project.example.com`
|
||||
|
||||
Interactive API docs: `https://fastapi-project.example.com/docs`
|
||||
|
||||
Adminer: `https://adminer.fastapi-project.example.com`
|
||||
+51
-195
@@ -1,241 +1,97 @@
|
||||
# FastAPI Project - Deployment
|
||||
|
||||
You can deploy the project using Docker Compose to a remote server.
|
||||
Deploy the project to [FastAPI Cloud](https://fastapicloud.com) with the included GitHub Actions workflow.
|
||||
|
||||
The deployment Docker Compose configuration includes Traefik to handle HTTPS and route incoming traffic to the application.
|
||||
## Create the FastAPI Cloud Application
|
||||
|
||||
You can use CI/CD (continuous integration and continuous deployment) systems to deploy automatically, there are already configurations to do it with GitHub Actions.
|
||||
Create an application in FastAPI Cloud and set its [Application Directory](https://fastapicloud.com/docs/builds-and-deployments/application-directory/) to `backend`.
|
||||
|
||||
But you have to configure a couple things first. 🤓
|
||||
Connect a PostgreSQL database using the [Neon](https://fastapicloud.com/docs/integrations/neon-integration/) or [Supabase](https://fastapicloud.com/docs/integrations/supabase-integration/) integration. Both integrations configure a `DATABASE_URL` secret automatically. You can also configure `DATABASE_URL` manually for another PostgreSQL provider.
|
||||
|
||||
## Preparation
|
||||
## Configure the Application
|
||||
|
||||
* Have a remote server ready and available. Use a separate server for each environment, for example one for staging and one for production.
|
||||
* Configure DNS records pointing to the server for the application domain and any supporting service subdomains you want to expose, e.g. `fastapi-project.example.com` and `adminer.fastapi-project.example.com`.
|
||||
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
||||
### Environment Variables
|
||||
|
||||
## Deploy the FastAPI Project
|
||||
Add these required [environment variables](https://fastapicloud.com/docs/builds-and-deployments/environment-variables/) to the FastAPI Cloud application:
|
||||
|
||||
You can deploy your FastAPI project with Docker Compose.
|
||||
* `PROJECT_NAME`: The name of the project, used in the API documentation and emails.
|
||||
* `FIRST_SUPERUSER`: The email address of the first superuser.
|
||||
* `FRONTEND_HOST`: The public URL of the application, such as the generated `https://your-app.fastapicloud.dev` URL or a custom domain.
|
||||
|
||||
**Note**: You might want to jump ahead to the section about Continuous Deployment with GitHub Actions.
|
||||
To enable emails, add these optional environment variables with values from your email provider:
|
||||
|
||||
## Copy the Code
|
||||
* `SMTP_HOST`
|
||||
* `SMTP_USER`
|
||||
* `EMAILS_FROM_EMAIL`
|
||||
|
||||
```bash
|
||||
rsync -av --exclude=".git/" --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
||||
```
|
||||
To enable Sentry, configure `SENTRY_DSN`.
|
||||
|
||||
Note: `--filter=":- .gitignore"` tells `rsync` to use the same rules as git, ignore files ignored by git, like the Python virtual environment.
|
||||
### Secrets
|
||||
|
||||
## Environment Variables
|
||||
Add these required values and mark them as secrets:
|
||||
|
||||
You need to set some environment variables first.
|
||||
* `SECRET_KEY`: A secret key used to sign security tokens.
|
||||
* `FIRST_SUPERUSER_PASSWORD`: The password of the first superuser.
|
||||
* `DATABASE_URL`: The PostgreSQL connection URL, configured automatically when using a database integration.
|
||||
|
||||
### Generate secret keys
|
||||
To enable emails with an authenticated provider, add `SMTP_PASSWORD` as a secret.
|
||||
|
||||
Some environment variables in the `.env` file have a default value of `changethis`.
|
||||
|
||||
You have to change them with a secret key, to generate secret keys you can run the following command:
|
||||
You can generate secure values for `SECRET_KEY` and `FIRST_SUPERUSER_PASSWORD` with:
|
||||
|
||||
```bash
|
||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
```
|
||||
|
||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
||||
## Configure Continuous Deployment
|
||||
|
||||
### Required Environment Variables
|
||||
The included `.github/workflows/deploy.yml` workflow builds the frontend, prepares the database, and deploys the application whenever changes are pushed to `master`. You can also run it manually from the **Actions** tab.
|
||||
|
||||
Set the `DOMAIN` to your own domain, for example:
|
||||
Log in to FastAPI Cloud and configure the [deploy token](https://fastapicloud.com/docs/advanced-features/deploy-tokens/) and application ID as GitHub repository secrets:
|
||||
|
||||
```bash
|
||||
export DOMAIN=fastapi-project.example.com
|
||||
uv run fastapi login
|
||||
uv run fastapi cloud setup-ci --secrets-only --app-id <your-app-id>
|
||||
```
|
||||
|
||||
The deployment Docker Compose configuration also uses `DOMAIN` to set the public frontend URL used in links generated by the backend.
|
||||
If the GitHub CLI is installed and authenticated, the command configures `FASTAPI_CLOUD_TOKEN` and `FASTAPI_CLOUD_APP_ID` automatically. Otherwise, it prints the values so you can add them in your repository under **Settings** > **Secrets and variables** > **Actions**.
|
||||
|
||||
Set the `POSTGRES_PASSWORD` to a secure value:
|
||||
The workflow runs database migrations and creates the first superuser before deploying. In the repository's **Settings** > **Secrets and variables** > **Actions** page, add these repository variables:
|
||||
|
||||
```bash
|
||||
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
```
|
||||
|
||||
Set the `SECRET_KEY`, used to sign tokens, to a secure value:
|
||||
|
||||
```bash
|
||||
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
```
|
||||
|
||||
Set the `FIRST_SUPERUSER_PASSWORD` to a secure value:
|
||||
|
||||
```bash
|
||||
export FIRST_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
```
|
||||
|
||||
You can set several other environment variables:
|
||||
|
||||
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
|
||||
* `BACKEND_CORS_ORIGINS`: A list of additional allowed CORS origins separated by commas. The frontend served by FastAPI uses the same origin and doesn't need to be added.
|
||||
* `FIRST_SUPERUSER`: The email of the first superuser, this superuser will be the one that can create new users.
|
||||
* `SMTP_HOST`: The SMTP server host to send emails, this would come from your email provider (E.g. Mailgun, Sparkpost, Sendgrid, etc).
|
||||
* `SMTP_USER`: The SMTP server user to send emails.
|
||||
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
||||
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
||||
* `POSTGRES_USER`: The Postgres user, you can leave the default.
|
||||
* `POSTGRES_DB`: The database name to use for this application. You can leave the default of `app`.
|
||||
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
|
||||
|
||||
## GitHub Actions Environment Variables
|
||||
|
||||
There are some environment variables only used by GitHub Actions that you can configure:
|
||||
|
||||
* `LATEST_CHANGES`: Used by the GitHub Action [latest-changes](https://github.com/tiangolo/latest-changes) to automatically add release notes based on the PRs merged. It's a personal access token, read the docs for details.
|
||||
* `SMOKESHOW_AUTH_KEY`: Used to handle and publish the code coverage using [Smokeshow](https://github.com/samuelcolvin/smokeshow), follow their instructions to create a (free) Smokeshow key.
|
||||
|
||||
### Deploy with Docker Compose
|
||||
|
||||
With the environment variables in place, you can deploy with Docker Compose:
|
||||
|
||||
```bash
|
||||
cd /root/code/app/
|
||||
docker compose -f compose.yml -f compose.deploy.yml build
|
||||
docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||
```
|
||||
|
||||
The `compose.deploy.yml` file adds the deployment settings to the shared configuration in `compose.yml`, including HTTPS and automatic certificate handling. Explicitly listing these files also excludes the local development settings in `compose.override.yml`.
|
||||
|
||||
## Continuous Deployment (CD)
|
||||
|
||||
You can use GitHub Actions to deploy your project automatically. 😎
|
||||
|
||||
There are already two environment deployments configured, `staging` and `production`. Each environment should be deployed to a separate server. 🚀
|
||||
|
||||
### Install GitHub Actions Runner
|
||||
|
||||
* On your remote server, create a user for your GitHub Actions:
|
||||
|
||||
```bash
|
||||
sudo adduser github
|
||||
```
|
||||
|
||||
* Add Docker permissions to the `github` user:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG docker github
|
||||
```
|
||||
|
||||
* Temporarily switch to the `github` user:
|
||||
|
||||
```bash
|
||||
sudo su - github
|
||||
```
|
||||
|
||||
* Go to the `github` user's home directory:
|
||||
|
||||
```bash
|
||||
cd
|
||||
```
|
||||
|
||||
* [Install a GitHub Action self-hosted runner following the official guide](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners#adding-a-self-hosted-runner-to-a-repository).
|
||||
|
||||
* When asked about labels, add a label for the environment, e.g. `production`. You can also add labels later.
|
||||
|
||||
After installing, the guide would tell you to run a command to start the runner. Nevertheless, it would stop once you terminate that process or if your local connection to your server is lost.
|
||||
|
||||
To make sure it runs on startup and continues running, you can install it as a service. To do that, exit the `github` user and go back to the `root` user:
|
||||
|
||||
```bash
|
||||
exit
|
||||
```
|
||||
|
||||
After you do it, you will be on the previous user again. And you will be on the previous directory, belonging to that user.
|
||||
|
||||
Before being able to go the `github` user directory, you need to become the `root` user (you might already be):
|
||||
|
||||
```bash
|
||||
sudo su
|
||||
```
|
||||
|
||||
* As the `root` user, go to the `actions-runner` directory inside of the `github` user's home directory:
|
||||
|
||||
```bash
|
||||
cd /home/github/actions-runner
|
||||
```
|
||||
|
||||
* Install the self-hosted runner as a service with the user `github`:
|
||||
|
||||
```bash
|
||||
./svc.sh install github
|
||||
```
|
||||
|
||||
* Start the service:
|
||||
|
||||
```bash
|
||||
./svc.sh start
|
||||
```
|
||||
|
||||
* Check the status of the service:
|
||||
|
||||
```bash
|
||||
./svc.sh status
|
||||
```
|
||||
|
||||
You can read more about it in the official guide: [Configuring the self-hosted runner application as a service](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service).
|
||||
|
||||
### Configure GitHub Environments
|
||||
|
||||
The deployment workflows use [GitHub Environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) for `staging` and `production`. This enables environment-specific secrets, deployment protection rules (e.g. required reviewers, wait timers), and deployment status tracking.
|
||||
|
||||
To configure them, go to your repository's **Settings** > **Environments** and create the `staging` and `production` environments.
|
||||
|
||||
### Set Secrets
|
||||
|
||||
For each GitHub Environment (`staging` and `production`), configure the required secrets as [environment secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-an-environment). Environment secrets are preferred over [repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository) because they are scoped to the specific environment, reducing exposure and aligning with any protection rules you configure.
|
||||
|
||||
The deployment workflows require these secrets:
|
||||
|
||||
* `DOMAIN`
|
||||
* `PROJECT_NAME`
|
||||
* `FIRST_SUPERUSER`
|
||||
* `FIRST_SUPERUSER_PASSWORD`
|
||||
* `POSTGRES_PASSWORD`
|
||||
|
||||
Add these repository secrets:
|
||||
|
||||
* `DATABASE_URL`
|
||||
* `SECRET_KEY`
|
||||
* `FIRST_SUPERUSER_PASSWORD`
|
||||
|
||||
To enable emails, configure these additional secrets with the values from your email provider:
|
||||
Use the same values configured in FastAPI Cloud. For `DATABASE_URL`, use the connection URL from your database provider. The database must be reachable from GitHub-hosted runners so the preparation step can connect to it.
|
||||
|
||||
* `SMTP_HOST`
|
||||
* `SMTP_USER`
|
||||
* `SMTP_PASSWORD`
|
||||
* `EMAILS_FROM_EMAIL`
|
||||
The deployment workflow performs these steps:
|
||||
|
||||
To enable Sentry, configure the `SENTRY_DSN` secret.
|
||||
|
||||
## GitHub Action Deployment Workflows
|
||||
|
||||
There are GitHub Action workflows in the `.github/workflows` directory already configured for deploying to the environments (GitHub Actions runners with the labels):
|
||||
|
||||
* `staging`: after pushing (or merging) to the branch `master`.
|
||||
* `production`: after publishing a release.
|
||||
|
||||
Both workflows are associated with their respective GitHub Environments, so deployments will be visible in the repository's **Environments** section and will respect any protection rules you configure.
|
||||
|
||||
If you need to add extra environments you could use those as a starting point.
|
||||
1. Installs and builds the frontend into `backend/app/frontend`.
|
||||
2. Runs `backend/scripts/prestart.sh` to apply database migrations and create the first superuser.
|
||||
3. Deploys the project with `uv run fastapi deploy`.
|
||||
|
||||
## URLs
|
||||
|
||||
Replace `fastapi-project.example.com` with your domain.
|
||||
Replace `your-app.fastapicloud.dev` with the URL of your FastAPI Cloud application.
|
||||
|
||||
### Production
|
||||
Application (frontend and API): `https://your-app.fastapicloud.dev`
|
||||
|
||||
Application (frontend and API): `https://fastapi-project.example.com`
|
||||
Interactive API docs: `https://your-app.fastapicloud.dev/docs`
|
||||
|
||||
Interactive API docs: `https://fastapi-project.example.com/docs`
|
||||
## Docker Compose
|
||||
|
||||
Adminer: `https://adminer.fastapi-project.example.com`
|
||||
For deployment to your own server, see the [Docker Compose deployment guide](./deployment-docker-compose.md).
|
||||
|
||||
### Staging
|
||||
## GitHub Repository Automation
|
||||
|
||||
Application (frontend and API): `https://staging.fastapi-project.example.com`
|
||||
Install the following GitHub Apps to enable the included repository automation:
|
||||
|
||||
Interactive API docs: `https://staging.fastapi-project.example.com/docs`
|
||||
* [Latest Changes](https://github.com/apps/latest-changes) updates `release-notes.md` when a pull request is merged.
|
||||
* [PR Push](https://github.com/apps/pr-push) lets the pre-commit workflow push automated fixes to pull request branches.
|
||||
* [PR Submit](https://github.com/apps/pr-submit) lets the **Bump pre-commit hooks** and **Prepare Release** workflows create pull requests.
|
||||
|
||||
Adminer: `https://adminer.staging.fastapi-project.example.com`
|
||||
To publish code coverage with [Smokeshow](https://github.com/samuelcolvin/smokeshow), add `SMOKESHOW_AUTH_KEY` as a repository secret.
|
||||
|
||||
+3
-48
@@ -57,6 +57,7 @@ The build is written to `backend/app/frontend` and served by FastAPI at <http://
|
||||
To run the backend and built frontend in Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend bash scripts/prestart.sh
|
||||
docker compose watch
|
||||
```
|
||||
|
||||
@@ -74,40 +75,12 @@ Mailcatcher: <http://localhost:1080>
|
||||
|
||||
Stop a locally running FastAPI server before starting the Compose backend because both use port `8000`.
|
||||
|
||||
**Note**: The first time you start the stack, it might take a minute to be ready while the backend waits for the database and configures everything. To monitor it, use `docker compose logs`, or `docker compose logs backend` for the backend service.
|
||||
**Note**: The first time you start the stack, it might take a minute for all the services to be ready. To monitor it, use `docker compose logs`, or `docker compose logs backend` for the backend service.
|
||||
|
||||
## Mailcatcher
|
||||
|
||||
Mailcatcher captures emails sent during local development instead of delivering them. The local backend connects to it at `localhost:1025`, and the Compose backend connects to the `mailcatcher` service. Captured emails are available at <http://localhost:1080>.
|
||||
|
||||
## Docker Compose in `localhost.tiangolo.com`
|
||||
|
||||
When you start the Docker Compose stack, it uses `localhost` by default, with different ports for each service (backend, adminer, etc).
|
||||
|
||||
When you deploy it to production (or staging), the application uses one domain. The frontend is served at `/` and the API lives under `/api`.
|
||||
|
||||
In the guide about [deployment](deployment.md) you can read about Traefik, the configured proxy. That's the component in charge of transmitting traffic to the application service based on the domain.
|
||||
|
||||
If you want to test that it's all working locally, you can edit the local `.env` file, and change:
|
||||
|
||||
```dotenv
|
||||
DOMAIN=localhost.tiangolo.com
|
||||
```
|
||||
|
||||
That will be used by the Docker Compose files to configure the base domain for the services.
|
||||
|
||||
Traefik will transmit application traffic at `localhost.tiangolo.com` to FastAPI, which serves both the frontend and API.
|
||||
|
||||
The domain `localhost.tiangolo.com` is a special domain that is configured (with all its subdomains) to point to `127.0.0.1`. This way you can use that for your local development.
|
||||
|
||||
After you update it, run again:
|
||||
|
||||
```bash
|
||||
docker compose watch
|
||||
```
|
||||
|
||||
Traefik is included in the main Docker Compose file. The development overrides expose its local dashboard so you can inspect the routes used with `localhost.tiangolo.com`.
|
||||
|
||||
## Docker Compose files and env vars
|
||||
|
||||
There is a main `compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
|
||||
@@ -118,8 +91,6 @@ The `compose.deploy.yml` file contains the deployment-specific settings, includi
|
||||
|
||||
The backend reads local settings from the `.env` file. Docker Compose also uses it for variable interpolation and passes the settings each container needs.
|
||||
|
||||
They also use some additional configurations taken from environment variables set in the scripts before calling the `docker compose` command.
|
||||
|
||||
After changing variables, make sure you restart the stack:
|
||||
|
||||
```bash
|
||||
@@ -185,7 +156,7 @@ biome check..............................................................Passed
|
||||
|
||||
## URLs
|
||||
|
||||
The production or staging URLs would use these same paths, but with your own domain.
|
||||
The deployed URLs use these same paths, but with your own domain.
|
||||
|
||||
### Development URLs
|
||||
|
||||
@@ -202,19 +173,3 @@ Adminer: <http://localhost:8080>
|
||||
Traefik UI: <http://localhost:8090>
|
||||
|
||||
MailCatcher: <http://localhost:1080>
|
||||
|
||||
### Development URLs with `localhost.tiangolo.com` Configured
|
||||
|
||||
Development URLs, for local development.
|
||||
|
||||
Application: <http://localhost.tiangolo.com>
|
||||
|
||||
Automatic Interactive Docs (Swagger UI): <http://localhost.tiangolo.com/docs>
|
||||
|
||||
Automatic Alternative Docs (ReDoc): <http://localhost.tiangolo.com/redoc>
|
||||
|
||||
Adminer: <http://localhost.tiangolo.com:8080>
|
||||
|
||||
Traefik UI: <http://localhost.tiangolo.com:8090>
|
||||
|
||||
MailCatcher: <http://localhost.tiangolo.com:1080>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+7
-3
@@ -15,9 +15,9 @@ bun run dev
|
||||
|
||||
* Then open your browser at http://localhost:5173/.
|
||||
|
||||
Run the backend locally with `uv run fastapi dev` and PostgreSQL in Docker Compose. See [../development.md](../development.md) for the complete setup.
|
||||
Run `uv run bash scripts/prestart.sh` and `uv run fastapi dev` from the `backend` directory, with PostgreSQL running in Docker Compose. See [../development.md](../development.md) for the complete setup.
|
||||
|
||||
To serve the frontend with FastAPI, run `bun run build` from the `frontend` directory and open `http://localhost:8000`. To run the full stack with Docker Compose, use `docker compose watch`.
|
||||
To serve the frontend with FastAPI, run `bun run build` from the `frontend` directory and open `http://localhost:8000`.
|
||||
|
||||
Check the file `package.json` to see other available options.
|
||||
|
||||
@@ -33,13 +33,16 @@ If you are developing an API-only app and want to remove the frontend, you can d
|
||||
|
||||
* In the `compose.override.yml` file, remove the `playwright` service.
|
||||
|
||||
* In the `.github/workflows/deploy.yml` file, remove the **Set up Bun**, **Install frontend dependencies**, and **Build frontend** steps.
|
||||
|
||||
* In the `.fastapicloudignore` file, remove the `!backend/app/frontend/` entry.
|
||||
|
||||
Done, you have a frontend-less (api-only) app. 🤓
|
||||
|
||||
## Generate Client
|
||||
|
||||
### Automatically
|
||||
|
||||
* Activate the backend virtual environment.
|
||||
* From the top level project directory, run the script:
|
||||
|
||||
```bash
|
||||
@@ -90,6 +93,7 @@ The frontend code is structured as follows:
|
||||
The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command:
|
||||
|
||||
```bash
|
||||
docker compose run --rm backend bash scripts/prestart.sh
|
||||
docker compose up -d --wait backend
|
||||
```
|
||||
|
||||
|
||||
+14
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"files": {
|
||||
"includes": [
|
||||
@@ -16,7 +16,7 @@
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"preset": "recommended",
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noArrayIndexKey": "off"
|
||||
@@ -42,5 +42,17 @@
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.svg"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noSvgWithoutTitle": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+30
-30
@@ -13,54 +13,54 @@
|
||||
"test:ui": "bunx playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-avatar": "^1.1.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.4",
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||
"@radix-ui/react-label": "^2.1.9",
|
||||
"@radix-ui/react-radio-group": "^1.4.0",
|
||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
||||
"@radix-ui/react-select": "^2.3.0",
|
||||
"@radix-ui/react-separator": "^1.1.9",
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@radix-ui/react-avatar": "^1.2.6",
|
||||
"@radix-ui/react-checkbox": "^1.3.11",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.4.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.14",
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.15",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"@tanstack/react-router": "^1.170.18",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "1.18.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"form-data": "4.0.6",
|
||||
"lucide-react": "^1.17.0",
|
||||
"lucide-react": "^1.28.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-hook-form": "^7.84.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.16",
|
||||
"@biomejs/biome": "^2.5.6",
|
||||
"@hey-api/openapi-ts": "0.97.3",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@tanstack/router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.18",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.3.1",
|
||||
"@tanstack/router-plugin": "^1.168.23",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@vitejs/plugin-react-swc": "^4.3.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.16"
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,29 +9,18 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SignupRouteImport } from './routes/signup'
|
||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
||||
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
|
||||
import { Route as SignupRouteImport } from './routes/signup'
|
||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
||||
id: '/reset-password',
|
||||
path: '/reset-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
|
||||
id: '/recover-password',
|
||||
path: '/recover-password',
|
||||
const LayoutRoute = LayoutRouteImport.update({
|
||||
id: '/_layout',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
@@ -39,8 +28,19 @@ const LoginRoute = LoginRouteImport.update({
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LayoutRoute = LayoutRouteImport.update({
|
||||
id: '/_layout',
|
||||
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
|
||||
id: '/recover-password',
|
||||
path: '/recover-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ResetPasswordRoute = ResetPasswordRouteImport.update({
|
||||
id: '/reset-password',
|
||||
path: '/reset-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||
@@ -48,9 +48,9 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
||||
@@ -58,9 +58,9 @@ const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
||||
path: '/items',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
|
||||
@@ -140,25 +140,11 @@ export interface RootRouteChildren {
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/signup': {
|
||||
id: '/signup'
|
||||
path: '/signup'
|
||||
fullPath: '/signup'
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/reset-password': {
|
||||
id: '/reset-password'
|
||||
path: '/reset-password'
|
||||
fullPath: '/reset-password'
|
||||
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/recover-password': {
|
||||
id: '/recover-password'
|
||||
path: '/recover-password'
|
||||
fullPath: '/recover-password'
|
||||
preLoaderRoute: typeof RecoverPasswordRouteImport
|
||||
'/_layout': {
|
||||
id: '/_layout'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof LayoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
@@ -168,11 +154,25 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_layout': {
|
||||
id: '/_layout'
|
||||
path: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof LayoutRouteImport
|
||||
'/recover-password': {
|
||||
id: '/recover-password'
|
||||
path: '/recover-password'
|
||||
fullPath: '/recover-password'
|
||||
preLoaderRoute: typeof RecoverPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/reset-password': {
|
||||
id: '/reset-password'
|
||||
path: '/reset-password'
|
||||
fullPath: '/reset-password'
|
||||
preLoaderRoute: typeof ResetPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/signup': {
|
||||
id: '/signup'
|
||||
path: '/signup'
|
||||
fullPath: '/signup'
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_layout/': {
|
||||
@@ -182,11 +182,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutIndexRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/settings': {
|
||||
id: '/_layout/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||
'/_layout/admin': {
|
||||
id: '/_layout/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/items': {
|
||||
@@ -196,11 +196,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutItemsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/_layout/admin': {
|
||||
id: '/_layout/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||
'/_layout/settings': {
|
||||
id: '/_layout/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,15 +79,16 @@ test("Sign up with existing email", async ({ page }) => {
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
await expect(page).toHaveURL(/\/login$/)
|
||||
|
||||
await page.goto("/signup")
|
||||
|
||||
await fillForm(page, fullName, email, password, password)
|
||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||
|
||||
await page
|
||||
.getByText("The user with this email already exists in the system")
|
||||
.click()
|
||||
await expect(
|
||||
page.getByText("The user with this email already exists in the system"),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("Sign up with weak password", async ({ page }) => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"@": path.resolve(import.meta.dirname, "./src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
+5
-2
@@ -2,12 +2,15 @@
|
||||
"name": "fastapi-full-stack-template",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"frontend"
|
||||
"frontend",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun run --filter frontend dev",
|
||||
"lint": "bun run --filter frontend lint",
|
||||
"test": "bun run --filter frontend test",
|
||||
"test:ui": "bun run --filter frontend test:ui"
|
||||
"test:ui": "bun run --filter frontend test:ui",
|
||||
"email:dev": "bun run --filter emails dev",
|
||||
"email:export": "bun run --filter emails export"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["../../frontend/biome.json"]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Text } from "@react-email/components"
|
||||
import { LinkButton } from "../ui/Button"
|
||||
import { Callout, Detail } from "../ui/Callout"
|
||||
import { Heading } from "../ui/Heading"
|
||||
import { Layout } from "../ui/Layout"
|
||||
import { Link } from "../ui/Link"
|
||||
|
||||
type NewAccountProps = {
|
||||
project_name: string
|
||||
username: string
|
||||
password: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export default function NewAccount({
|
||||
project_name = "{{ project_name }}",
|
||||
username = "{{ username }}",
|
||||
password = "{{ password }}",
|
||||
link = "{{ link }}",
|
||||
}: NewAccountProps) {
|
||||
return (
|
||||
<Layout
|
||||
title={`${project_name} - New account`}
|
||||
preview={`Your ${project_name} account is ready`}
|
||||
project_name={project_name}
|
||||
>
|
||||
<Heading>Welcome to {project_name}!</Heading>
|
||||
<Text className="text-[15px] leading-7 text-body">Hi,</Text>
|
||||
<Text className="text-[15px] leading-7 text-body">
|
||||
Your account has been successfully created and is ready to use. These
|
||||
are your credentials:
|
||||
</Text>
|
||||
<Callout>
|
||||
<Detail label="Username" value={username} />
|
||||
<Detail label="Password" value={password} />
|
||||
</Callout>
|
||||
<Text className="text-[15px] leading-7 text-body">
|
||||
Get started by signing in to your dashboard:
|
||||
</Text>
|
||||
<LinkButton href={link}>Go to Dashboard</LinkButton>
|
||||
<Text className="text-sm leading-6 text-muted">
|
||||
Or copy and paste this link into your browser:
|
||||
<br />
|
||||
<Link href={link}>{link}</Link>
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-muted">
|
||||
For security reasons, change your password after your first sign in.
|
||||
</Text>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Text } from "@react-email/components"
|
||||
import { LinkButton } from "../ui/Button"
|
||||
import { Heading } from "../ui/Heading"
|
||||
import { Layout } from "../ui/Layout"
|
||||
import { Link } from "../ui/Link"
|
||||
|
||||
type ResetPasswordProps = {
|
||||
project_name: string
|
||||
username: string
|
||||
link: string
|
||||
valid_hours: string
|
||||
}
|
||||
|
||||
export default function ResetPassword({
|
||||
project_name = "{{ project_name }}",
|
||||
username = "{{ username }}",
|
||||
link = "{{ link }}",
|
||||
valid_hours = "{{ valid_hours }}",
|
||||
}: ResetPasswordProps) {
|
||||
return (
|
||||
<Layout
|
||||
title={`${project_name} - Password recovery`}
|
||||
preview={`Reset your ${project_name} password`}
|
||||
project_name={project_name}
|
||||
>
|
||||
<Heading>Reset your password</Heading>
|
||||
<Text className="text-[15px] leading-7 text-body">Hi {username},</Text>
|
||||
<Text className="text-[15px] leading-7 text-body">
|
||||
We've received a request to reset the password for your {project_name}{" "}
|
||||
account. Choose a new one by clicking the button below:
|
||||
</Text>
|
||||
<LinkButton href={link}>Reset password</LinkButton>
|
||||
<Text className="text-sm leading-6 text-muted">
|
||||
Or copy and paste this link into your browser:
|
||||
<br />
|
||||
<Link href={link}>{link}</Link>
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-muted">
|
||||
This link will expire in {valid_hours} hours.
|
||||
</Text>
|
||||
<Text className="text-sm leading-6 text-muted">
|
||||
If you didn't request a password recovery, you can safely ignore this
|
||||
email.
|
||||
</Text>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Text } from "@react-email/components"
|
||||
import { Callout, Detail } from "../ui/Callout"
|
||||
import { Heading } from "../ui/Heading"
|
||||
import { Layout } from "../ui/Layout"
|
||||
|
||||
type TestEmailProps = {
|
||||
project_name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export default function TestEmail({
|
||||
project_name = "{{ project_name }}",
|
||||
email = "{{ email }}",
|
||||
}: TestEmailProps) {
|
||||
return (
|
||||
<Layout
|
||||
title={`${project_name} - Test email`}
|
||||
preview={`${project_name} email delivery is working`}
|
||||
project_name={project_name}
|
||||
>
|
||||
<Heading>Test email</Heading>
|
||||
<Text className="text-[15px] leading-7 text-body">Hi,</Text>
|
||||
<Text className="text-[15px] leading-7 text-body">
|
||||
This is a test email from {project_name}. If you're reading it, email
|
||||
delivery is configured correctly.
|
||||
</Text>
|
||||
<Callout>
|
||||
<Detail label="Sent to" value={email} />
|
||||
</Callout>
|
||||
<Text className="text-sm leading-6 text-muted">
|
||||
If you weren't expecting this email, you can safely ignore it.
|
||||
</Text>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "emails",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "email build",
|
||||
"dev": "email dev --port 3333",
|
||||
"lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./",
|
||||
"export": "email export --outDir ../../backend/app/email-templates"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "1.0.12",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-email": "6.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.5.6",
|
||||
"@react-email/ui": "6.9.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules", ".react-email"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Button } from "@react-email/components"
|
||||
|
||||
type ButtonProps = React.PropsWithChildren & { href: string }
|
||||
|
||||
export const LinkButton = ({ children, href }: ButtonProps) => (
|
||||
<Button
|
||||
href={href}
|
||||
className="bg-brand text-white rounded-md font-semibold text-sm mt-2 mb-7 inline-block text-center no-underline px-7 py-3.5"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Section, Text } from "@react-email/components"
|
||||
|
||||
type CalloutProps = React.PropsWithChildren
|
||||
|
||||
export const Callout = ({ children }: CalloutProps) => (
|
||||
<Section className="border-0 border-l-2 border-solid border-brand pl-5 my-7">
|
||||
{children}
|
||||
</Section>
|
||||
)
|
||||
|
||||
type DetailProps = { label: string; value: string }
|
||||
|
||||
export const Detail = ({ label, value }: DetailProps) => (
|
||||
<Text className="m-0 my-3">
|
||||
<span className="block text-[11px] uppercase tracking-[0.08em] text-muted">
|
||||
{label}
|
||||
</span>
|
||||
<span className="block text-sm font-semibold break-all font-mono mt-1">
|
||||
{value}
|
||||
</span>
|
||||
</Text>
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Heading as BaseHeading } from "@react-email/components"
|
||||
|
||||
type HeadingProps = React.PropsWithChildren
|
||||
|
||||
export const Heading = ({ children }: HeadingProps) => (
|
||||
<BaseHeading
|
||||
as="h1"
|
||||
className="text-2xl leading-8 font-semibold tracking-[-0.02em] text-body m-0 mb-5"
|
||||
>
|
||||
{children}
|
||||
</BaseHeading>
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components"
|
||||
|
||||
type LayoutProps = React.PropsWithChildren & {
|
||||
title: string
|
||||
project_name: string
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export const Layout = ({
|
||||
project_name,
|
||||
title,
|
||||
preview,
|
||||
children,
|
||||
}: LayoutProps) => {
|
||||
return (
|
||||
<Tailwind
|
||||
config={{
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: "#ffffff",
|
||||
body: "#1e293b",
|
||||
muted: "#64748b",
|
||||
rule: "#e2e8f0",
|
||||
brand: {
|
||||
DEFAULT: "#009688",
|
||||
dark: "#00695c",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
'"Segoe UI"',
|
||||
"Roboto",
|
||||
"Oxygen",
|
||||
"Ubuntu",
|
||||
"Cantarell",
|
||||
'"Fira Sans"',
|
||||
'"Droid Sans"',
|
||||
'"Helvetica Neue"',
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Html lang="en" dir="ltr">
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
</Head>
|
||||
{preview ? <Preview>{preview}</Preview> : null}
|
||||
<Body className="bg-surface font-sans text-body max-w-[560px] w-full mx-auto my-0 py-12 px-6">
|
||||
<Section>
|
||||
<Hr className="border-0 border-t-2 border-solid border-brand w-8 m-0" />
|
||||
<Text className="text-body text-[13px] font-semibold uppercase tracking-[0.1em] mt-4 mb-9">
|
||||
{project_name}
|
||||
</Text>
|
||||
</Section>
|
||||
<Section>{children}</Section>
|
||||
<Hr className="border-0 border-t border-solid border-rule mt-10 mb-5" />
|
||||
<Section>
|
||||
<Text className="text-xs leading-5 text-muted m-0">
|
||||
© {new Date().getFullYear()} {project_name}. All rights reserved.
|
||||
</Text>
|
||||
</Section>
|
||||
</Body>
|
||||
</Html>
|
||||
</Tailwind>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
type LinkProps = React.PropsWithChildren & { href: string }
|
||||
|
||||
export const Link = ({ children, href }: LinkProps) => (
|
||||
<a href={href} className="text-brand-dark underline break-all">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"prek>=0.4.11,<1.0.0",
|
||||
"typer>=0.20.0,<1.0.0",
|
||||
"zizmor>=1.28.0",
|
||||
]
|
||||
|
||||
|
||||
+44
-2
@@ -2,13 +2,52 @@
|
||||
|
||||
## Latest Changes
|
||||
|
||||
## 0.12.0 (2026-08-12)
|
||||
|
||||
### Features
|
||||
|
||||
* 🔧 Use FastAPI entrypoint. PR [#2360](https://github.com/fastapi/full-stack-fastapi-template/pull/2360) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Add library-skills for FastAPI and SQLModel. PR [#2354](https://github.com/fastapi/full-stack-fastapi-template/pull/2354) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ☁️ Add FastAPI Cloud deployment. PR [#2438](https://github.com/fastapi/full-stack-fastapi-template/pull/2438) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Fixes
|
||||
|
||||
* 🐳 Include frontend assets in Docker build context. PR [#2440](https://github.com/fastapi/full-stack-fastapi-template/pull/2440) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Internal
|
||||
|
||||
* 👷 Migrate automatic labels to Latest Changes. PR [#2439](https://github.com/fastapi/full-stack-fastapi-template/pull/2439) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.11.1 (2026-08-11)
|
||||
|
||||
### Refactors
|
||||
|
||||
* ♻️ Simplify Docker Compose deployment. PR [#2435](https://github.com/fastapi/full-stack-fastapi-template/pull/2435) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Migrate to `DATABASE_URL` instead of separate variables. PR [#2184](https://github.com/fastapi/full-stack-fastapi-template/pull/2184) by [@patrick91](https://github.com/patrick91).
|
||||
|
||||
### Internal
|
||||
|
||||
* ⬆ Bump the npm-packages group across 1 directory with 33 updates. PR [#2433](https://github.com/fastapi/full-stack-fastapi-template/pull/2433) by [@dependabot[bot]](https://github.com/apps/dependabot).
|
||||
|
||||
## 0.11.0 (2026-08-11)
|
||||
|
||||
### Features
|
||||
|
||||
* ✨ Add react-email for email templates. PR [#2421](https://github.com/fastapi/full-stack-fastapi-template/pull/2421) by [@alejsdev](https://github.com/alejsdev).
|
||||
* 🔧 Use FastAPI entrypoint. PR [#2360](https://github.com/fastapi/full-stack-fastapi-template/pull/2360) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Add library-skills for FastAPI and SQLModel. PR [#2354](https://github.com/fastapi/full-stack-fastapi-template/pull/2354) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Fixes
|
||||
|
||||
* ✅ Fix sign-up test race. PR [#2422](https://github.com/fastapi/full-stack-fastapi-template/pull/2422) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
### Refactors
|
||||
|
||||
* ♻️ Remove Copier project generation. PR [#2430](https://github.com/fastapi/full-stack-fastapi-template/pull/2430) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Simplify CORS configuration. PR [#2429](https://github.com/fastapi/full-stack-fastapi-template/pull/2429) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Use `FASTAPI_ENV` for development. PR [#2427](https://github.com/fastapi/full-stack-fastapi-template/pull/2427) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Run database setup explicitly before startup, no need for prestart. PR [#2426](https://github.com/fastapi/full-stack-fastapi-template/pull/2426) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Require explicit SECRET_KEY. PR [#2425](https://github.com/fastapi/full-stack-fastapi-template/pull/2425) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Simplify uv workspace setup. PR [#2423](https://github.com/fastapi/full-stack-fastapi-template/pull/2423) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Use FastAPI entrypoint in Compose. PR [#2420](https://github.com/fastapi/full-stack-fastapi-template/pull/2420) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Require non-empty Compose configuration. PR [#2418](https://github.com/fastapi/full-stack-fastapi-template/pull/2418) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Simplify optional environment configuration. PR [#2417](https://github.com/fastapi/full-stack-fastapi-template/pull/2417) by [@tiangolo](https://github.com/tiangolo).
|
||||
* ♻️ Simplify Copier environment configuration. PR [#2416](https://github.com/fastapi/full-stack-fastapi-template/pull/2416) by [@tiangolo](https://github.com/tiangolo).
|
||||
@@ -30,6 +69,7 @@
|
||||
|
||||
### Docs
|
||||
|
||||
* 📝 Remove obsolete local domain setup. PR [#2428](https://github.com/fastapi/full-stack-fastapi-template/pull/2428) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 📝 Clarify environment configuration. PR [#2419](https://github.com/fastapi/full-stack-fastapi-template/pull/2419) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 📝 Improve README screenshot alt text. PR [#2359](https://github.com/fastapi/full-stack-fastapi-template/pull/2359) by [@Bmowville](https://github.com/Bmowville).
|
||||
* ✏️ Fix typo in the comment for DOMAIN in the .env file. PR [#2305](https://github.com/fastapi/full-stack-fastapi-template/pull/2305) by [@serhiiur](https://github.com/serhiiur).
|
||||
@@ -38,6 +78,8 @@
|
||||
|
||||
### Internal
|
||||
|
||||
* 👷 Add release automation. PR [#2431](https://github.com/fastapi/full-stack-fastapi-template/pull/2431) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 👷 Remove legacy label check. PR [#2424](https://github.com/fastapi/full-stack-fastapi-template/pull/2424) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 🔐 Use PR Submit for pull requests. PR [#2411](https://github.com/fastapi/full-stack-fastapi-template/pull/2411) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 👷 Use GitHub CLI for Git authentication. PR [#2410](https://github.com/fastapi/full-stack-fastapi-template/pull/2410) by [@tiangolo](https://github.com/tiangolo).
|
||||
* 👷 Use PR Push commit identity. PR [#2409](https://github.com/fastapi/full-stack-fastapi-template/pull/2409) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Prepare a release by updating and extracting release notes."""
|
||||
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
RELEASE_NOTES_FILE = Path("release-notes.md")
|
||||
RELEASE_NOTES_HEADER = "# Release Notes\n\n"
|
||||
LATEST_CHANGES_HEADER = "## Latest Changes"
|
||||
VERSION_HEADING_PATTERN = re.compile(r"(?m)^## (\d+\.\d+\.\d+)(?: \([^)]+\))?$")
|
||||
BumpType = Literal["major", "minor", "patch"]
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
|
||||
def parse_version(version: str) -> tuple[int, int, int]:
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
|
||||
raise ValueError(f"Invalid version: {version!r}. Expected format: X.Y.Z")
|
||||
major, minor, patch = version.split(".")
|
||||
return int(major), int(minor), int(patch)
|
||||
|
||||
|
||||
def get_current_version(content: str, release_notes_file: Path) -> str:
|
||||
match = VERSION_HEADING_PATTERN.search(content)
|
||||
if not match:
|
||||
raise RuntimeError(f"Could not find a version section in {release_notes_file}")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def bump_version(version: str, bump: BumpType) -> str:
|
||||
major, minor, patch = parse_version(version)
|
||||
if bump == "major":
|
||||
return f"{major + 1}.0.0"
|
||||
if bump == "minor":
|
||||
return f"{major}.{minor + 1}.0"
|
||||
return f"{major}.{minor}.{patch + 1}"
|
||||
|
||||
|
||||
def update_release_notes(
|
||||
content: str, version: str, release_date: date, release_notes_file: Path
|
||||
) -> str:
|
||||
latest_header = f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n"
|
||||
if not content.startswith(latest_header):
|
||||
raise RuntimeError(f"{release_notes_file} must start with {latest_header!r}")
|
||||
if re.search(rf"^## {re.escape(version)}(?: \([^)]+\))?$", content, re.M):
|
||||
raise RuntimeError(f"Release notes already contain a section for {version}")
|
||||
|
||||
current_version = get_current_version(content, release_notes_file)
|
||||
if parse_version(version) <= parse_version(current_version):
|
||||
raise RuntimeError(
|
||||
f"New version {version} must be greater than current version "
|
||||
f"{current_version}"
|
||||
)
|
||||
|
||||
current_match = VERSION_HEADING_PATTERN.search(content)
|
||||
assert current_match is not None
|
||||
latest_changes = content[len(latest_header) : current_match.start()].strip()
|
||||
if not latest_changes:
|
||||
raise RuntimeError("The Latest Changes section is empty")
|
||||
|
||||
release_header = f"## {version} ({release_date.isoformat()})"
|
||||
return content.replace(
|
||||
latest_header,
|
||||
f"{RELEASE_NOTES_HEADER}{LATEST_CHANGES_HEADER}\n\n{release_header}\n",
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
def get_release_notes_body(content: str, version: str, release_notes_file: Path) -> str:
|
||||
version_heading = re.compile(rf"(?m)^## {re.escape(version)}(?: \([^)]+\))?$")
|
||||
match = version_heading.search(content)
|
||||
if not match:
|
||||
raise RuntimeError(
|
||||
f"Could not find release notes section for {version} in "
|
||||
f"{release_notes_file}"
|
||||
)
|
||||
|
||||
next_match = VERSION_HEADING_PATTERN.search(content, match.end())
|
||||
end = next_match.start() if next_match else len(content)
|
||||
body = content[match.end() : end].strip()
|
||||
if not body:
|
||||
raise RuntimeError(
|
||||
f"Release notes section for {version} in {release_notes_file} is empty"
|
||||
)
|
||||
return f"{body}\n"
|
||||
|
||||
|
||||
def prepare_release(
|
||||
bump: BumpType, release_date: date, release_notes_file: Path
|
||||
) -> str:
|
||||
content = release_notes_file.read_text(encoding="utf-8")
|
||||
version = bump_version(get_current_version(content, release_notes_file), bump)
|
||||
release_notes_file.write_text(
|
||||
update_release_notes(content, version, release_date, release_notes_file),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
@app.command()
|
||||
def prepare(
|
||||
bump: Annotated[
|
||||
BumpType,
|
||||
typer.Argument(help="The release bump to make: major, minor, or patch."),
|
||||
],
|
||||
release_notes_file: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
writable=True,
|
||||
help="Path to the release notes Markdown file.",
|
||||
),
|
||||
] = RELEASE_NOTES_FILE,
|
||||
release_date: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--date",
|
||||
help="Release date in YYYY-MM-DD format. Defaults to today.",
|
||||
),
|
||||
] = date.today().isoformat(),
|
||||
) -> None:
|
||||
parsed_release_date = date.fromisoformat(release_date or date.today().isoformat())
|
||||
version = prepare_release(bump, parsed_release_date, release_notes_file)
|
||||
typer.echo(f"Prepared release {version} ({parsed_release_date.isoformat()})")
|
||||
|
||||
|
||||
@app.command()
|
||||
def current_version(
|
||||
release_notes_file: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
help="Path to the release notes Markdown file.",
|
||||
),
|
||||
] = RELEASE_NOTES_FILE,
|
||||
) -> None:
|
||||
typer.echo(
|
||||
get_current_version(
|
||||
release_notes_file.read_text(encoding="utf-8"), release_notes_file
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def release_notes(
|
||||
release_notes_file: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
help="Path to the release notes Markdown file.",
|
||||
),
|
||||
] = RELEASE_NOTES_FILE,
|
||||
) -> None:
|
||||
content = release_notes_file.read_text(encoding="utf-8")
|
||||
version = get_current_version(content, release_notes_file)
|
||||
typer.echo(
|
||||
get_release_notes_body(content, version, release_notes_file),
|
||||
nl=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -11,5 +11,6 @@ if [ $(uname -s) = "Linux" ]; then
|
||||
fi
|
||||
|
||||
docker-compose build
|
||||
docker-compose run --rm backend bash scripts/prestart.sh
|
||||
docker-compose up -d
|
||||
docker-compose exec -T backend bash scripts/tests-start.sh "$@"
|
||||
|
||||
@@ -6,6 +6,7 @@ set -x
|
||||
|
||||
docker compose build
|
||||
docker compose down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error
|
||||
docker compose run --rm backend bash scripts/prestart.sh
|
||||
docker compose up -d
|
||||
docker compose exec -T backend bash scripts/tests-start.sh "$@"
|
||||
docker compose down -v --remove-orphans
|
||||
|
||||
@@ -14,6 +14,7 @@ members = [
|
||||
[manifest.dependency-groups]
|
||||
dev = [
|
||||
{ name = "prek", specifier = ">=0.4.11,<1.0.0" },
|
||||
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||
{ name = "zizmor", specifier = ">=1.28.0" },
|
||||
]
|
||||
github-actions = [{ name = "smokeshow", specifier = ">=0.5.0" }]
|
||||
@@ -529,7 +530,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/89/aaafc8e14de4ac882e02ccb963225329b0e8578aba4365e71eb678e45722/greenlet-3.5.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1c31219badba285858ba8ed117f403dea7fafee6bade9a1991875aae530c3ceb", size = 287676, upload-time = "2026-06-17T17:33:31.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/fc/2308249206c12ac70de7b9a00970f84f07d10b3cd60e05d2fbcaa84124e8/greenlet-3.5.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f96ed6f4adc1066954ae95f45717657cb67468ef3b89e9a3632e14a625a8f39", size = 653552, upload-time = "2026-06-17T18:07:23.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/24/47730d1f8f1336b9b089237521ed7a26eee997065dcb4cab81cdca333abc/greenlet-3.5.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5795e883e915333c0d5648faaa691857fbc7180136883edc377f50f0d509c2a8", size = 665756, upload-time = "2026-06-17T18:29:46.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/5c/2664d290cbd1fef9eb3f69b5d3bc5aa91b6fa907519298ca6af93a90c6cb/greenlet-3.5.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e9e49d732ee92a189bb7035e293029244aeba648297a9b856dc733d17ca7f0d", size = 669989, upload-time = "2026-06-17T18:39:30.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/69/d6c99db15dc0b5e892ac3cc7b942c8b21f4a9cc3bd9ea0bc3b0f339ffbd4/greenlet-3.5.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26aed8d9503ca78889141a9739d71b383efea5f472a7c522b5410f7eb2a1b163", size = 663228, upload-time = "2026-06-17T17:39:31.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/d4/fcb53fa9847d7fbd4723fbed9469c3869b9e3544c4e001d9d5aa2f66162d/greenlet-3.5.2-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:537c5c4f30395020bb9f48f53146070e3b997c3c75da14011ab732aaa19ce3ef", size = 472888, upload-time = "2026-06-17T18:41:22.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/88/9e603f448e2bc107c883e95817b980fb9b45ba6aea0299b2e9978124bea2/greenlet-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dbebc038fcdda8f8f21cce985fd04e34e0f42007e7fc7ab7ad285caf77974b95", size = 1620723, upload-time = "2026-06-17T18:22:14.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/91/26da17e3777858c16fdb8d020a4c68f3a03cb92f238de8f5351d5d5186e9/greenlet-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a207023f1cf8695fd82580b8099c09c5809be18bc2282362cdfb965dd884a317", size = 1684227, upload-time = "2026-06-17T17:40:09.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/44/b3a11f7aa34cb38f1b7f3df8bcd9fcd09bac9d342c2a2c9b8686c804bcd2/greenlet-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:c674a1dd4fe41f6a93febe7ab366ceabf15080ea31a9307811c56dac5f435f73", size = 240257, upload-time = "2026-06-17T17:35:23.359Z" },
|
||||
@@ -537,14 +540,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/d3bad483e9f6cd1848604fdffa32cac25846dd6dfcec0e6f81c790185518/greenlet-3.5.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a96457a30384de52d9c5d2fd33abf6c1daae3db392cd556738f408b1a79a1cf0", size = 295668, upload-time = "2026-06-17T17:36:02.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/e9/3a7e557b895fd0469b00cd0b2bd498ba950e8bfdf6d7adeecf2c5e4130a6/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4af5d4961818ab651d09c1448a03b1ba2a1726a076266ebb62330bab9f3238c", size = 652820, upload-time = "2026-06-17T18:07:24.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/67/6225d5c5e4afc04be0fd161eec82e4b72017e8a100d222f25d7b42b0140d/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1789a6244ea1ba61fd4386c9a6a31873e9b0234762103364be98ef87dcb19f3", size = 658697, upload-time = "2026-06-17T18:29:48.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ad/9b3058f999b81750a9c6d9ec424f509462d232b58002086fe2ba63b66407/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ee6288f1933d698b4f098127ed17bda2910a75d2807915bd16294a972055d6c", size = 658945, upload-time = "2026-06-17T18:39:32.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/99/6324b8ef916dcaddccb340b304c992ca3f947614ce0f2685d438187300b8/greenlet-3.5.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3be00501fb4a8c37f6b4b3c4773808ceb26ea65c7ea64fd5735d0f330b3786de", size = 656436, upload-time = "2026-06-17T17:39:32.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/75/1b6ecd8c027b69ab1b6798a84094df79aab5e69ac7e249c78b9d361dd1fa/greenlet-3.5.2-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b4cad42662c796334c2d24607c411e3ed82481c1fb4e1e8ec3a5a8416060092e", size = 490529, upload-time = "2026-06-17T18:41:23.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ee/f5bf9daac27c5e1b011965f64b5630a32b415daf7381b312943629e12c2a/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1d554cd96841a68d464d75a3736f8e87408a7b02b1930a75fa32feb408ad62f8", size = 1617193, upload-time = "2026-06-17T18:22:16.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/21/b05d5b12715bda92ce27c118d64971d21e9b8f3563ed959a7d271e2d4223/greenlet-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3dff6cd3aac35f6cd3fc23460105acf576f5faf6c378de0bc088bf37c913864a", size = 1677512, upload-time = "2026-06-17T17:40:10.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/97/1b8f1314b868041b327dc1051603e8142b826480cb0ecb8a7b7632aee9c4/greenlet-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36cfea2aa075d544617176b2e84450480f0797070ad8799a8c41ada2fe449d32", size = 243145, upload-time = "2026-06-17T17:34:37.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/07/1b5311775e04c718a118c504d7a3a312430e2a1bd1347226aff4774e4549/greenlet-3.5.2-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:a0314aa832c94633355dc6f3ee54f195159533355a323f26926fc63b98b2ccbb", size = 288315, upload-time = "2026-06-17T17:34:34.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/cc/6abcd2a486b58b9f77b7a93b690d59cb2c11a5906ed2ad4c63c7b9c1113d/greenlet-3.5.2-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24c59cb7db9d5c694cb8fd0c76eef8e456b2123afdfa7e4b8f2a67a0860d7682", size = 659130, upload-time = "2026-06-17T18:07:26.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/12/f4aaad6d3d383233f700ab322568a4f29f2c701a4861d85f4811d99689b2/greenlet-3.5.2-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7bb811753703739ad318112f16eccfaabdac050037b6d092debaa8b23566b4ce", size = 669724, upload-time = "2026-06-17T18:29:50.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/e0/4ce3a046b51e53934eae93d7f9c13975a97285741e9e1fcadf8751314c37/greenlet-3.5.2-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2debcd0ef9455b7d4879589903efc8e497d4b8fb8c0ae772309e44d1ca5e957f", size = 673494, upload-time = "2026-06-17T18:39:34.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2a/a089811fc31c6bf8742f40a4e73470d6d401cef18e4314eb20dc399b377c/greenlet-3.5.2-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d78b5c1c178dad90447f1b8452262709d3eef4c98f825569e74c9d0b2260ac9", size = 668089, upload-time = "2026-06-17T17:39:33.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e0/9c18721e63445dce02ee67e4c81c0f281626604ff55ae6f7b7f4354d7129/greenlet-3.5.2-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:9558cae989faeab6fbb425cd98a0cfa4190a47fba6443973fbee0a1eb0b0b6c3", size = 479721, upload-time = "2026-06-17T18:41:25.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1c/2f47c7d5fcfa98a62b705bf9a0505d86f4563c0d81cab1f7159ff1e743b7/greenlet-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0977af2df83136f81c1f76e76d4e2fe7d0dc56ea9c101a86af26a95190b9ca32", size = 1625684, upload-time = "2026-06-17T18:22:17.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/bf/661dd24624f70b7b32972d7693d0344ecde10278f647d7b828baf739899c/greenlet-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f9ed777c6891d8253e54468576f55e27f8fc1a662a664f946a191003574c0a74", size = 1688043, upload-time = "2026-06-17T17:40:12.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/49/d9bde1d15a21296b3b521fe083eb8aabd54ac05d15de9832918f3d639543/greenlet-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:c0ea4eb3de23f0bac1d75205e10ccfa9b418b17b01a2d7bf19e3b69dda08900a", size = 240531, upload-time = "2026-06-17T17:35:47.448Z" },
|
||||
@@ -552,7 +559,9 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/15/907be5e8900901039bae752fa9a31c03a3c1e064833f35a4e49449184581/greenlet-3.5.2-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:98a52d6a50d4deaba304331d83ee3e10ebbdc1517fcca40b2715d1de4534065c", size = 296697, upload-time = "2026-06-17T17:37:15.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/5c/08c57be575c3d6a3c023bbf22144a1c7dc6ed4d134527bb36ded4dbf04a8/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1587ff8b58fdf806993ed1490a06ac19c22d47b219c68b30954380029045d8d4", size = 656710, upload-time = "2026-06-17T18:07:28.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d0/749f917bdc9fc90fceea4aa65fbf6556e617a50714d1496bdc8ad190bb36/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:feb721811d2754bfd16b48de151dd6b1f222c048e625151f2ca44cfdfd69f59c", size = 662629, upload-time = "2026-06-17T18:29:51.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/87/10776cd88df54d0f563e9e21e98363f2d6af94bedc553b1da0972fa87f80/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9476cbead736dc48ce89e3cd97acff95ecc48cbf21273603a438f9870c4a014", size = 663191, upload-time = "2026-06-17T18:39:35.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/a5/68cefae3a07f6d0093a490cf28ab604f14578f3e60205a2a2b2d5cd70af2/greenlet-3.5.2-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fe6062b1f35534e1e8fb28dfed406cf4eeff3e0bca3a0d9f8ff69f20a4abb00", size = 660147, upload-time = "2026-06-17T17:39:35.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/aa/26ddf92826a99d87bfb8fdb8f3a262a6f16495a5d8e579737baa92fb4543/greenlet-3.5.2-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:5930d3946ecae99fa7fc0e3f3ae515426ad85058ebd9bfc6c00cca8016e6206b", size = 498199, upload-time = "2026-06-17T18:41:27.464Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/6b/b9156d8397e4750220f54c7c5c34650f1e740a8d2f66eab9cfd1b7b53b69/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b4ac902af825cbac8e9b2fccab8122236fd2ba6c8b71a080116d2c2ec72671b1", size = 1621675, upload-time = "2026-06-17T18:22:18.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/e3/d3250f4fa01c211a93d04e34fded63187e648dbec17b9b1a14d388040593/greenlet-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6f1e473c06ae8be00c9034c2bb10fa277b08a93287e3111c395b839f01d27e1f", size = 1680577, upload-time = "2026-06-17T17:40:14.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/ba/eaee8bda4419770d7096b5a009ebff0ab20a2a28cdd83c4b591bfdf36fa9/greenlet-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:3c2315045f9983e2e50d7e89d95405c21bddb8745f2da4487bc080ab3525f904", size = 243482, upload-time = "2026-06-17T17:37:34.741Z" },
|
||||
|
||||
Reference in New Issue
Block a user