mirror of
https://github.com/fastapi/full-stack-fastapi-template.git
synced 2026-11-03 20:36:27 +00:00
Compare commits
44 Commits
66f444a63a
...
0.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 6c2122e37d | |||
| 2c28c6f7dc | |||
| a5aff58850 | |||
| 060795204b | |||
| 1a30f30e51 | |||
| dd15054da0 | |||
| bda0325818 | |||
| 2c7e51505e | |||
| 0625bcc8b0 | |||
| 4632845a39 | |||
| 029e929253 | |||
| 8a213ddae7 |
@@ -1 +0,0 @@
|
|||||||
{{ _copier_answers|to_json -}}
|
|
||||||
@@ -1,26 +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()
|
|
||||||
lines = []
|
|
||||||
for line in env_content.splitlines():
|
|
||||||
for key, value in answers.items():
|
|
||||||
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,40 +1,18 @@
|
|||||||
# Domain
|
# Enable development behavior for commands that import the app directly
|
||||||
# This would be set to the production domain with an env var on deployment
|
FASTAPI_ENV=development
|
||||||
# used by Traefik to transmit traffic and acquire TLS certificates
|
|
||||||
DOMAIN=localhost
|
|
||||||
# To test the local Traefik config
|
|
||||||
# DOMAIN=localhost.tiangolo.com
|
|
||||||
|
|
||||||
# Public frontend URL, used by the backend to generate links in emails.
|
|
||||||
# Deployments derive this from DOMAIN in compose.deploy.yml.
|
|
||||||
FRONTEND_HOST=http://localhost:5173
|
|
||||||
|
|
||||||
# Environment: local, staging, production
|
|
||||||
ENVIRONMENT=local
|
|
||||||
|
|
||||||
PROJECT_NAME="Full Stack FastAPI Project"
|
PROJECT_NAME="Full Stack FastAPI Project"
|
||||||
|
|
||||||
# Backend
|
|
||||||
# Additional allowed CORS origins, separated by commas
|
|
||||||
BACKEND_CORS_ORIGINS=
|
|
||||||
SECRET_KEY=changethis
|
SECRET_KEY=changethis
|
||||||
FIRST_SUPERUSER=admin@example.com
|
FIRST_SUPERUSER=admin@example.com
|
||||||
FIRST_SUPERUSER_PASSWORD=changethis
|
FIRST_SUPERUSER_PASSWORD=changethis
|
||||||
|
|
||||||
# Emails
|
# Emails
|
||||||
SMTP_HOST=
|
SMTP_HOST=localhost
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
EMAILS_FROM_EMAIL=info@example.com
|
EMAILS_FROM_EMAIL=info@example.com
|
||||||
SMTP_TLS=True
|
SMTP_TLS=False
|
||||||
SMTP_SSL=False
|
SMTP_PORT=1025
|
||||||
SMTP_PORT=587
|
|
||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
POSTGRES_SERVER=localhost
|
|
||||||
POSTGRES_PORT=5432
|
|
||||||
POSTGRES_DB=app
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_PASSWORD=changethis
|
POSTGRES_PASSWORD=changethis
|
||||||
|
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/app
|
||||||
SENTRY_DSN=
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
workflows:
|
workflows:
|
||||||
- .github/workflows/bump-pre-commit-hooks.yml
|
- .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)"
|
||||||
@@ -1,37 +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:
|
|
||||||
ENVIRONMENT: production
|
|
||||||
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,23 +1,21 @@
|
|||||||
name: Deploy to Staging
|
name: Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- release-notes.md
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
environment: staging
|
|
||||||
# Do not deploy in the main repository, only in user projects
|
# Do not deploy in the main repository, only in user projects
|
||||||
if: github.repository_owner != 'fastapi'
|
if: github.repository_owner != 'fastapi'
|
||||||
runs-on:
|
runs-on: self-hosted
|
||||||
- self-hosted
|
|
||||||
- staging
|
|
||||||
env:
|
env:
|
||||||
ENVIRONMENT: staging
|
|
||||||
DOMAIN: ${{ secrets.DOMAIN }}
|
DOMAIN: ${{ secrets.DOMAIN }}
|
||||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
||||||
@@ -34,4 +32,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
- run: docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
@@ -5,9 +5,6 @@ on:
|
|||||||
- opened
|
- opened
|
||||||
- synchronize
|
- synchronize
|
||||||
- reopened
|
- reopened
|
||||||
# For label-checker
|
|
||||||
- labeled
|
|
||||||
- unlabeled
|
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -20,18 +17,4 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||||
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
|
|
||||||
- run: echo "Done adding labels"
|
- 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: bash scripts/generate-client.sh
|
||||||
- run: docker compose build
|
- run: docker compose build
|
||||||
- run: docker compose down -v --remove-orphans
|
- run: docker compose down -v --remove-orphans
|
||||||
|
- run: docker compose run --rm backend bash scripts/prestart.sh
|
||||||
- name: Run Playwright tests
|
- 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 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
|
- 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
|
persist-credentials: false
|
||||||
- run: docker compose build
|
- run: docker compose build
|
||||||
- run: docker compose down -v --remove-orphans
|
- 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
|
- run: docker compose up -d --wait backend adminer
|
||||||
- name: Test backend is up
|
- name: Test backend is up
|
||||||
run: curl http://localhost:8000/api/v1/utils/health-check
|
run: curl http://localhost:8000/api/v1/utils/health-check
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ repos:
|
|||||||
exclude: |
|
exclude: |
|
||||||
(?x)^(
|
(?x)^(
|
||||||
frontend/src/client/.*|
|
frontend/src/client/.*|
|
||||||
backend/app/email-templates/build/.*
|
backend/app/email-templates/.*
|
||||||
)$
|
)$
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude: ^frontend/src/client/.*
|
exclude: ^frontend/src/client/.*
|
||||||
@@ -64,7 +64,7 @@ repos:
|
|||||||
entry: bash ./scripts/generate-client.sh
|
entry: bash ./scripts/generate-client.sh
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
language: unsupported
|
language: unsupported
|
||||||
files: ^backend/.*$|^scripts/generate-client\.sh$
|
files: ^backend/app/.*\.py$|^backend/pyproject\.toml$|^uv\.lock$|^frontend/openapi-ts\.config\.ts$|^scripts/generate-client\.sh$
|
||||||
|
|
||||||
- id: add-release-date
|
- id: add-release-date
|
||||||
language: unsupported
|
language: unsupported
|
||||||
|
|||||||
Vendored
-1
@@ -7,7 +7,6 @@
|
|||||||
"charliermarsh.ruff",
|
"charliermarsh.ruff",
|
||||||
"docker.docker",
|
"docker.docker",
|
||||||
"github.vscode-github-actions",
|
"github.vscode-github-actions",
|
||||||
"mjmlio.vscode-mjml",
|
|
||||||
"ms-playwright.playwright",
|
"ms-playwright.playwright",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"tombi-toml.tombi"
|
"tombi-toml.tombi"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
- 🤖 An automatically generated frontend client.
|
- 🤖 An automatically generated frontend client.
|
||||||
- 🧪 [Playwright](https://playwright.dev) for End-to-End testing.
|
- 🧪 [Playwright](https://playwright.dev) for End-to-End testing.
|
||||||
- 🦇 Dark mode support.
|
- 🦇 Dark mode support.
|
||||||
- 🐋 [Docker Compose](https://www.docker.com) for development and production.
|
- 🐋 [Docker Compose](https://www.docker.com) for local services and deployment.
|
||||||
- 🔒 Secure password hashing by default.
|
- 🔒 Secure password hashing by default.
|
||||||
- 🔑 JWT (JSON Web Token) authentication.
|
- 🔑 JWT (JSON Web Token) authentication.
|
||||||
- 📫 Email based password recovery.
|
- 📫 Email based password recovery.
|
||||||
@@ -147,65 +147,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.
|
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).
|
|
||||||
- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env.
|
|
||||||
- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env.
|
|
||||||
- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later 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.
|
|
||||||
- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
|
|
||||||
|
|
||||||
## Backend Development
|
## Backend Development
|
||||||
|
|
||||||
Backend docs: [backend/README.md](./backend/README.md).
|
Backend docs: [backend/README.md](./backend/README.md).
|
||||||
@@ -222,7 +163,7 @@ Deployment docs: [deployment.md](./deployment.md).
|
|||||||
|
|
||||||
General development docs: [development.md](./development.md).
|
General development docs: [development.md](./development.md).
|
||||||
|
|
||||||
This includes using Docker Compose, custom local domains, `.env` configurations, etc.
|
This includes the local FastAPI and Vite workflow, Docker Compose services, custom local domains, `.env` configuration, and more.
|
||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
|||||||
+40
-67
@@ -5,27 +5,29 @@
|
|||||||
* [Docker](https://www.docker.com/).
|
* [Docker](https://www.docker.com/).
|
||||||
* [uv](https://docs.astral.sh/uv/) for Python package and environment management.
|
* [uv](https://docs.astral.sh/uv/) for Python package and environment management.
|
||||||
|
|
||||||
## Docker Compose
|
## Local Development
|
||||||
|
|
||||||
Start the local development environment with Docker Compose following the guide in [../development.md](../development.md).
|
Run the backend locally and connect it to PostgreSQL in Docker Compose.
|
||||||
|
|
||||||
## General Workflow
|
From the project root, start PostgreSQL and Mailcatcher:
|
||||||
|
|
||||||
By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it.
|
```console
|
||||||
|
$ docker compose up -d db mailcatcher
|
||||||
|
```
|
||||||
|
|
||||||
From `./backend/` you can install all the dependencies with:
|
Then, from `./backend/`, install the dependencies, prepare the database, and start the development server:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ uv sync
|
$ uv sync
|
||||||
|
$ uv run bash scripts/prestart.sh
|
||||||
|
$ uv run fastapi dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Then you can activate the virtual environment with:
|
The API is available at `http://localhost:8000`, with automatic interactive docs at `http://localhost:8000/docs`.
|
||||||
|
|
||||||
```console
|
## General Workflow
|
||||||
$ 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`.
|
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`.
|
||||||
|
|
||||||
@@ -35,66 +37,33 @@ There are already configurations in place to run the backend through the VS Code
|
|||||||
|
|
||||||
The setup is also already configured so you can run the tests through the VS Code Python tests tab.
|
The setup is also already configured so you can run the tests through the VS Code Python tests tab.
|
||||||
|
|
||||||
## Docker Compose Override
|
## Full Stack with Docker Compose
|
||||||
|
|
||||||
During development, you can change Docker Compose settings that will only affect the local development environment in the file `compose.override.yml`.
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
The changes to that file only affect the local development environment, not the production environment. So, you can add "temporary" changes that help the development workflow.
|
|
||||||
|
|
||||||
For example, the directory with the backend code is synchronized in the Docker container, copying the code you change live to the directory inside the container. That allows you to test your changes right away, without having to build the Docker image again. It should only be done during development, for production, you should build the Docker image with a recent version of the backend code. But during development, it allows you to iterate very fast.
|
|
||||||
|
|
||||||
There is also a command override that runs `fastapi run --reload` instead of the default `fastapi run`. It starts a single server process (instead of multiple, as would be for production) and reloads the process whenever the code changes. Have in mind that if you have a syntax error and save the Python file, it will break and exit, and the container will stop. After that, you can restart the container by fixing the error and running again:
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
$ docker compose run --rm backend bash scripts/prestart.sh
|
||||||
$ docker compose watch
|
$ docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
There is also a commented out `command` override, you can uncomment it and comment the default one. It makes the backend container run a process that does "nothing", but keeps the container alive. That allows you to get inside your running container and execute commands inside, for example a Python interpreter to test installed dependencies, or start the development server that reloads when it detects changes.
|
The application is available at `http://localhost:8000`.
|
||||||
|
|
||||||
To get inside the container with a `bash` session you can start the stack with:
|
### Docker Compose Override
|
||||||
|
|
||||||
```console
|
The `compose.override.yml` file contains local settings for published ports, source synchronization, automatic image rebuilds, and backend reloads. Docker Compose applies it automatically when you run `docker compose` without an explicit file list.
|
||||||
$ docker compose watch
|
|
||||||
```
|
|
||||||
|
|
||||||
and then in another terminal, `exec` inside the running container:
|
To open a shell in the backend container:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ docker compose exec backend bash
|
$ docker compose exec backend bash
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see an output like:
|
|
||||||
|
|
||||||
```console
|
|
||||||
root@7f2607af31c3:/app#
|
|
||||||
```
|
|
||||||
|
|
||||||
that means that you are in a `bash` session inside your container, as a `root` user, under the `/app` directory, this directory has another directory called "app" inside, that's where your code lives inside the container: `/app/app`.
|
|
||||||
|
|
||||||
There you can use the `fastapi run --reload` command to run the debug live reloading server.
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ fastapi run --reload app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
...it will look like:
|
|
||||||
|
|
||||||
```console
|
|
||||||
root@7f2607af31c3:/app# fastapi run --reload app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
and then hit enter. That runs the live reloading server that auto reloads when it detects code changes.
|
|
||||||
|
|
||||||
Nevertheless, if it doesn't detect a change but a syntax error, it will just stop with an error. But as the container is still alive and you are in a Bash session, you can quickly restart it after fixing the error, running the same command ("up arrow" and "Enter").
|
|
||||||
|
|
||||||
...this previous detail is what makes it useful to have the container alive doing nothing and then, in a Bash session, make it run the live reload server.
|
|
||||||
|
|
||||||
## Backend tests
|
## Backend tests
|
||||||
|
|
||||||
To test the backend run:
|
To test the backend from the `backend` directory, run:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ bash ./scripts/test.sh
|
$ uv run bash ./scripts/test.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The tests run with Pytest, modify and add tests to `./backend/tests/`.
|
The tests run with Pytest, modify and add tests to `./backend/tests/`.
|
||||||
@@ -123,22 +92,14 @@ When the tests are run, a file `htmlcov/index.html` is generated, you can open i
|
|||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
As during local development your app directory is mounted as a volume inside the container, you can also run the migrations with `alembic` commands inside the container and the migration code will be in your app directory (instead of being only inside the container). So you can add it to your git repository.
|
Make sure you create a revision of your models and upgrade the database with that revision every time you change them. From the `backend` directory, use `uv` to run Alembic against the PostgreSQL container:
|
||||||
|
|
||||||
Make sure you create a "revision" of your models and that you "upgrade" your database with that revision every time you change them. As this is what will update the tables in your database. Otherwise, your application will have errors.
|
|
||||||
|
|
||||||
* Start an interactive session in the backend container:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ docker compose exec backend bash
|
|
||||||
```
|
|
||||||
|
|
||||||
* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`.
|
* Alembic is already configured to import your SQLModel models from `./backend/app/models.py`.
|
||||||
|
|
||||||
* After changing a model (for example, adding a column), inside the container, create a revision, e.g.:
|
* After changing a model (for example, adding a column), create a revision:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ alembic revision --autogenerate -m "Add column last_name to User model"
|
$ uv run alembic revision --autogenerate -m "Add column last_name to User model"
|
||||||
```
|
```
|
||||||
|
|
||||||
* Commit to the git repository the files generated in the alembic directory.
|
* Commit to the git repository the files generated in the alembic directory.
|
||||||
@@ -146,7 +107,7 @@ $ alembic revision --autogenerate -m "Add column last_name to User model"
|
|||||||
* After creating the revision, run the migration in the database (this is what will actually change the database):
|
* After creating the revision, run the migration in the database (this is what will actually change the database):
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ alembic upgrade head
|
$ uv run alembic upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in:
|
If you don't want to use migrations at all, uncomment the lines in the file at `./backend/app/core/db.py` that end in:
|
||||||
@@ -165,8 +126,20 @@ If you don't want to start with the default models and want to remove them / mod
|
|||||||
|
|
||||||
## Email Templates
|
## 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():
|
def get_url():
|
||||||
return str(settings.SQLALCHEMY_DATABASE_URI)
|
return str(settings.DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline():
|
def run_migrations_offline():
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ api_router.include_router(utils.router)
|
|||||||
api_router.include_router(items.router)
|
api_router.include_router(items.router)
|
||||||
|
|
||||||
|
|
||||||
if settings.ENVIRONMENT == "local":
|
if settings.FASTAPI_ENV == "development":
|
||||||
api_router.include_router(private.router)
|
api_router.include_router(private.router)
|
||||||
|
|||||||
+16
-43
@@ -1,27 +1,17 @@
|
|||||||
import secrets
|
|
||||||
import warnings
|
import warnings
|
||||||
from typing import Annotated, Any, Literal, Self
|
from typing import Literal, Self
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
AnyUrl,
|
|
||||||
BeforeValidator,
|
|
||||||
EmailStr,
|
EmailStr,
|
||||||
HttpUrl,
|
HttpUrl,
|
||||||
PostgresDsn,
|
PostgresDsn,
|
||||||
computed_field,
|
computed_field,
|
||||||
|
field_validator,
|
||||||
model_validator,
|
model_validator,
|
||||||
)
|
)
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
# Use top level .env file (one level above ./backend/)
|
# Use top level .env file (one level above ./backend/)
|
||||||
@@ -30,42 +20,24 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
SECRET_KEY: str
|
||||||
# 60 minutes * 24 hours * 8 days = 8 days
|
# 60 minutes * 24 hours * 8 days = 8 days
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
|
||||||
FRONTEND_HOST: str = "http://localhost:5173"
|
FRONTEND_HOST: str = "http://localhost:5173"
|
||||||
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
|
FASTAPI_ENV: Literal["development"] | 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
|
|
||||||
]
|
|
||||||
|
|
||||||
PROJECT_NAME: str
|
PROJECT_NAME: str
|
||||||
SENTRY_DSN: HttpUrl | None = None
|
SENTRY_DSN: HttpUrl | None = None
|
||||||
POSTGRES_SERVER: str
|
DATABASE_URL: PostgresDsn
|
||||||
POSTGRES_PORT: int = 5432
|
|
||||||
POSTGRES_USER: str
|
|
||||||
POSTGRES_PASSWORD: str = ""
|
|
||||||
POSTGRES_DB: str = ""
|
|
||||||
|
|
||||||
@computed_field # type: ignore[prop-decorator]
|
@field_validator("DATABASE_URL", mode="before")
|
||||||
@property
|
@classmethod
|
||||||
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
|
def _use_psycopg_driver(cls, value: str | PostgresDsn) -> str:
|
||||||
return PostgresDsn.build(
|
database_url = str(value)
|
||||||
scheme="postgresql+psycopg",
|
for scheme in ("postgres://", "postgresql://"):
|
||||||
username=self.POSTGRES_USER,
|
if database_url.startswith(scheme):
|
||||||
password=self.POSTGRES_PASSWORD,
|
return database_url.replace(scheme, "postgresql+psycopg://", 1)
|
||||||
host=self.POSTGRES_SERVER,
|
return database_url
|
||||||
port=self.POSTGRES_PORT,
|
|
||||||
path=self.POSTGRES_DB,
|
|
||||||
)
|
|
||||||
|
|
||||||
SMTP_TLS: bool = True
|
SMTP_TLS: bool = True
|
||||||
SMTP_SSL: bool = False
|
SMTP_SSL: bool = False
|
||||||
@@ -99,7 +71,7 @@ class Settings(BaseSettings):
|
|||||||
f'The value of {var_name} is "changethis", '
|
f'The value of {var_name} is "changethis", '
|
||||||
"for security, please change it, at least for deployments."
|
"for security, please change it, at least for deployments."
|
||||||
)
|
)
|
||||||
if self.ENVIRONMENT == "local":
|
if self.FASTAPI_ENV == "development":
|
||||||
warnings.warn(message, stacklevel=1)
|
warnings.warn(message, stacklevel=1)
|
||||||
else:
|
else:
|
||||||
raise ValueError(message)
|
raise ValueError(message)
|
||||||
@@ -107,7 +79,8 @@ class Settings(BaseSettings):
|
|||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _enforce_non_default_secrets(self) -> Self:
|
def _enforce_non_default_secrets(self) -> Self:
|
||||||
self._check_default_secret("SECRET_KEY", self.SECRET_KEY)
|
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(
|
self._check_default_secret(
|
||||||
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
|
"FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from app import crud
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import User, UserCreate
|
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
|
# 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
+8
-10
@@ -15,7 +15,7 @@ def custom_generate_unique_id(route: APIRoute) -> str:
|
|||||||
return f"{route.tags[0]}-{route.name}"
|
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)
|
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -24,15 +24,13 @@ app = FastAPI(
|
|||||||
generate_unique_id_function=custom_generate_unique_id,
|
generate_unique_id_function=custom_generate_unique_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set all CORS enabled origins
|
app.add_middleware(
|
||||||
if settings.all_cors_origins:
|
CORSMiddleware,
|
||||||
app.add_middleware(
|
allow_origins=[settings.FRONTEND_HOST],
|
||||||
CORSMiddleware,
|
allow_credentials=True,
|
||||||
allow_origins=settings.all_cors_origins,
|
allow_methods=["*"],
|
||||||
allow_credentials=True,
|
allow_headers=["*"],
|
||||||
allow_methods=["*"],
|
)
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
app.include_router(api_router, prefix=settings.API_V1_STR)
|
app.include_router(api_router, prefix=settings.API_V1_STR)
|
||||||
app.frontend("/", directory=FRONTEND_DIR)
|
app.frontend("/", directory=FRONTEND_DIR)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class EmailData:
|
|||||||
|
|
||||||
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
def render_email_template(*, template_name: str, context: dict[str, Any]) -> str:
|
||||||
template_str = (
|
template_str = (
|
||||||
Path(__file__).parent / "email-templates" / "build" / template_name
|
Path(__file__).parent / "email-templates" / template_name
|
||||||
).read_text()
|
).read_text()
|
||||||
html_content = Template(template_str).render(context)
|
html_content = Template(template_str).render(context)
|
||||||
return html_content
|
return html_content
|
||||||
|
|||||||
+3
-7
@@ -38,23 +38,19 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
labels:
|
labels:
|
||||||
# Route HTTPS traffic for the Adminer subdomain
|
# Route HTTPS traffic for the Adminer subdomain
|
||||||
- traefik.http.routers.adminer-https.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
- traefik.http.routers.adminer-https.rule=Host(`adminer.${DOMAIN:?Variable not set}`)
|
||||||
- traefik.http.routers.adminer-https.entrypoints=https
|
- traefik.http.routers.adminer-https.entrypoints=https
|
||||||
- traefik.http.routers.adminer-https.tls=true
|
- traefik.http.routers.adminer-https.tls=true
|
||||||
# Use the Let's Encrypt resolver
|
# Use the Let's Encrypt resolver
|
||||||
- traefik.http.routers.adminer-https.tls.certresolver=le
|
- traefik.http.routers.adminer-https.tls.certresolver=le
|
||||||
|
|
||||||
prestart:
|
|
||||||
environment:
|
|
||||||
FRONTEND_HOST: https://${DOMAIN?Variable not set}
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
FRONTEND_HOST: https://${DOMAIN?Variable not set}
|
FRONTEND_HOST: https://${DOMAIN:?Variable not set}
|
||||||
labels:
|
labels:
|
||||||
# Route HTTPS traffic for this domain
|
# Route HTTPS traffic for this domain
|
||||||
- traefik.http.routers.backend-https.rule=Host(`${DOMAIN?Variable not set}`)
|
- traefik.http.routers.backend-https.rule=Host(`${DOMAIN:?Variable not set}`)
|
||||||
- traefik.http.routers.backend-https.entrypoints=https
|
- traefik.http.routers.backend-https.entrypoints=https
|
||||||
- traefik.http.routers.backend-https.tls=true
|
- traefik.http.routers.backend-https.tls=true
|
||||||
# Use the Let's Encrypt resolver
|
# Use the Let's Encrypt resolver
|
||||||
|
|||||||
+7
-12
@@ -1,9 +1,5 @@
|
|||||||
services:
|
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:
|
proxy:
|
||||||
image: traefik:3.6
|
image: traefik:3.6
|
||||||
ports:
|
ports:
|
||||||
@@ -44,16 +40,15 @@ services:
|
|||||||
# command: sleep infinity # Infinite loop to keep container alive doing nothing
|
# command: sleep infinity # Infinite loop to keep container alive doing nothing
|
||||||
command:
|
command:
|
||||||
- fastapi
|
- fastapi
|
||||||
- run
|
- dev
|
||||||
- --reload
|
- --host
|
||||||
- "app/main.py"
|
- "0.0.0.0"
|
||||||
develop:
|
develop:
|
||||||
watch:
|
watch:
|
||||||
- path: ./backend
|
- path: ./backend
|
||||||
action: sync
|
action: sync
|
||||||
target: /app/backend
|
target: /app/backend
|
||||||
ignore:
|
ignore:
|
||||||
- ./backend/.venv
|
|
||||||
- .venv
|
- .venv
|
||||||
- path: ./backend/pyproject.toml
|
- path: ./backend/pyproject.toml
|
||||||
action: rebuild
|
action: rebuild
|
||||||
@@ -68,10 +63,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend/htmlcov:/app/backend/htmlcov
|
- ./backend/htmlcov:/app/backend/htmlcov
|
||||||
environment:
|
environment:
|
||||||
|
FASTAPI_ENV: "development"
|
||||||
SMTP_HOST: "mailcatcher"
|
SMTP_HOST: "mailcatcher"
|
||||||
SMTP_PORT: "1025"
|
SMTP_PORT: "1025"
|
||||||
SMTP_TLS: "false"
|
SMTP_TLS: "false"
|
||||||
EMAILS_FROM_EMAIL: "noreply@example.com"
|
|
||||||
|
|
||||||
mailcatcher:
|
mailcatcher:
|
||||||
image: schickling/mailcatcher
|
image: schickling/mailcatcher
|
||||||
@@ -89,15 +84,15 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
|
- FIRST_SUPERUSER=${FIRST_SUPERUSER:?Variable not set}
|
||||||
|
- FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||||
- PLAYWRIGHT_BASE_URL=http://backend:8000
|
- PLAYWRIGHT_BASE_URL=http://backend:8000
|
||||||
- VITE_API_URL=http://backend:8000
|
- VITE_API_URL=http://backend:8000
|
||||||
- MAILCATCHER_HOST=http://mailcatcher:1080
|
- MAILCATCHER_HOST=http://mailcatcher:1080
|
||||||
# For the reports when run locally
|
# For the reports when run locally
|
||||||
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
- PLAYWRIGHT_HTML_HOST=0.0.0.0
|
||||||
- CI=${CI}
|
- CI=${CI:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/blob-report:/app/frontend/blob-report
|
- ./frontend/blob-report:/app/frontend/blob-report
|
||||||
- ./frontend/test-results:/app/frontend/test-results
|
- ./frontend/test-results:/app/frontend/test-results
|
||||||
|
|||||||
+14
-42
@@ -20,19 +20,16 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U postgres -d app"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
volumes:
|
volumes:
|
||||||
- app-db-data:/var/lib/postgresql
|
- app-db-data:/var/lib/postgresql
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Variable not set}
|
||||||
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
|
- POSTGRES_DB=app
|
||||||
- POSTGRES_DB=${POSTGRES_DB?Variable not set}
|
|
||||||
|
|
||||||
adminer:
|
adminer:
|
||||||
image: adminer
|
image: adminer
|
||||||
@@ -44,7 +41,7 @@ services:
|
|||||||
# Enable Traefik for this service
|
# Enable Traefik for this service
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
# Route HTTP traffic for the Adminer subdomain
|
# Route HTTP traffic for the Adminer subdomain
|
||||||
- traefik.http.routers.adminer-http.rule=Host(`adminer.${DOMAIN?Variable not set}`)
|
- traefik.http.routers.adminer-http.rule=Host(`adminer.${DOMAIN:-localhost}`)
|
||||||
- traefik.http.routers.adminer-http.entrypoints=http
|
- traefik.http.routers.adminer-http.entrypoints=http
|
||||||
# Define the port inside of the Docker service to use
|
# Define the port inside of the Docker service to use
|
||||||
- traefik.http.services.adminer.loadbalancer.server.port=8080
|
- traefik.http.services.adminer.loadbalancer.server.port=8080
|
||||||
@@ -55,30 +52,17 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
prestart:
|
environment:
|
||||||
condition: service_completed_successfully
|
PROJECT_NAME: ${PROJECT_NAME:?Variable not set}
|
||||||
# The YAML anchor lets the prestart service reuse this environment mapping.
|
SECRET_KEY: ${SECRET_KEY:?Variable not set}
|
||||||
environment: &backend-environment
|
FIRST_SUPERUSER: ${FIRST_SUPERUSER:?Variable not set}
|
||||||
FRONTEND_HOST: ${FRONTEND_HOST?Variable not set}
|
FIRST_SUPERUSER_PASSWORD: ${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||||
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}
|
|
||||||
SMTP_HOST: ${SMTP_HOST}
|
SMTP_HOST: ${SMTP_HOST}
|
||||||
SMTP_USER: ${SMTP_USER}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
EMAILS_FROM_EMAIL: ${EMAILS_FROM_EMAIL}
|
EMAILS_FROM_EMAIL: ${EMAILS_FROM_EMAIL}
|
||||||
SMTP_TLS: ${SMTP_TLS}
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:?Variable not set}@db:5432/app
|
||||||
SMTP_SSL: ${SMTP_SSL}
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
SMTP_PORT: ${SMTP_PORT}
|
|
||||||
POSTGRES_SERVER: db
|
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER?Variable not set}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?Variable not set}
|
|
||||||
SENTRY_DSN: ${SENTRY_DSN}
|
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
|
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
|
||||||
@@ -97,20 +81,8 @@ services:
|
|||||||
- traefik.http.services.backend.loadbalancer.server.port=8000
|
- traefik.http.services.backend.loadbalancer.server.port=8000
|
||||||
|
|
||||||
# Route HTTP traffic for this domain
|
# Route HTTP traffic for this domain
|
||||||
- traefik.http.routers.backend-http.rule=Host(`${DOMAIN?Variable not set}`)
|
- traefik.http.routers.backend-http.rule=Host(`${DOMAIN:-localhost}`)
|
||||||
- traefik.http.routers.backend-http.entrypoints=http
|
- 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:
|
volumes:
|
||||||
app-db-data:
|
app-db-data:
|
||||||
|
|||||||
-95
@@ -1,95 +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
|
|
||||||
|
|
||||||
smtp_host:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server host to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_user:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server user to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
smtp_password:
|
|
||||||
type: str
|
|
||||||
help: The SMTP server password to send emails, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
sentry_dsn:
|
|
||||||
type: str
|
|
||||||
help: The DSN for Sentry, if you are using it, you can set it later in .env
|
|
||||||
default: ""
|
|
||||||
|
|
||||||
_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]
|
|
||||||
+26
-60
@@ -4,13 +4,13 @@ You can deploy the project using Docker Compose to a remote server.
|
|||||||
|
|
||||||
The deployment Docker Compose configuration includes Traefik to handle HTTPS and route incoming traffic to the application.
|
The deployment Docker Compose configuration includes Traefik to handle HTTPS and route incoming traffic to the 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.
|
The included GitHub Actions workflow can deploy the application automatically.
|
||||||
|
|
||||||
But you have to configure a couple things first. 🤓
|
But you have to configure a couple things first. 🤓
|
||||||
|
|
||||||
## Preparation
|
## Preparation
|
||||||
|
|
||||||
* Have a remote server ready and available. Use a separate server for each environment, for example one for staging and one for production.
|
* 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, e.g. `fastapi-project.example.com` and `adminer.fastapi-project.example.com`.
|
* 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).
|
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
||||||
|
|
||||||
@@ -46,13 +46,7 @@ Copy the content and use that as password / secret key. And run that again to ge
|
|||||||
|
|
||||||
### Required Environment Variables
|
### Required Environment Variables
|
||||||
|
|
||||||
Set the `ENVIRONMENT`, by default `local` (for development), but when deploying to a server you would put something like `staging` or `production`:
|
Set the `DOMAIN` to your own domain, for example:
|
||||||
|
|
||||||
```bash
|
|
||||||
export ENVIRONMENT=production
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `DOMAIN`, by default `localhost` (for development), but when deploying you would use your own domain, for example:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export DOMAIN=fastapi-project.example.com
|
export DOMAIN=fastapi-project.example.com
|
||||||
@@ -60,47 +54,43 @@ export DOMAIN=fastapi-project.example.com
|
|||||||
|
|
||||||
The deployment Docker Compose configuration also uses `DOMAIN` to set the public frontend URL used in links generated by the backend.
|
The deployment Docker Compose configuration also uses `DOMAIN` to set the public frontend URL used in links generated by the backend.
|
||||||
|
|
||||||
Set the `POSTGRES_PASSWORD` to something different than `changethis`:
|
Set the `POSTGRES_PASSWORD` to a secure value:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export POSTGRES_PASSWORD="changethis"
|
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
```
|
```
|
||||||
|
|
||||||
Set the `SECRET_KEY`, used to sign tokens:
|
Set the `SECRET_KEY`, used to sign tokens, to a secure value:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export SECRET_KEY="changethis"
|
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: you can use the Python command above to generate a secure secret key.
|
Set the `FIRST_SUPERUSER_PASSWORD` to a secure value:
|
||||||
|
|
||||||
Set the `FIRST_SUPERUSER_PASSWORD` to something different than `changethis`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export FIRST_SUPERUSER_PASSWORD="changethis"
|
export FIRST_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
```
|
```
|
||||||
|
|
||||||
You can set several other environment variables:
|
You can set several other environment variables:
|
||||||
|
|
||||||
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
|
* `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.
|
* `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_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_USER`: The SMTP server user to send emails.
|
||||||
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
||||||
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
||||||
* `POSTGRES_SERVER`: The hostname of the PostgreSQL server. You can leave the default of `db`, provided by the same Docker Compose. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `POSTGRES_PORT`: The port of the PostgreSQL server. You can leave the default. You normally wouldn't need to change this unless you are using a third-party provider.
|
|
||||||
* `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.
|
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
|
||||||
|
|
||||||
## GitHub Actions Environment Variables
|
## GitHub Repository Automation
|
||||||
|
|
||||||
There are some environment variables only used by GitHub Actions that you can configure:
|
Install the following GitHub Apps to enable the included repository automation:
|
||||||
|
|
||||||
* `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.
|
* [Latest Changes](https://github.com/apps/latest-changes) updates `release-notes.md` when a pull request is merged.
|
||||||
* `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.
|
* [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.
|
||||||
|
|
||||||
|
To publish code coverage with [Smokeshow](https://github.com/samuelcolvin/smokeshow), add `SMOKESHOW_AUTH_KEY` as a repository secret.
|
||||||
|
|
||||||
### Deploy with Docker Compose
|
### Deploy with Docker Compose
|
||||||
|
|
||||||
@@ -109,6 +99,7 @@ With the environment variables in place, you can deploy with Docker Compose:
|
|||||||
```bash
|
```bash
|
||||||
cd /root/code/app/
|
cd /root/code/app/
|
||||||
docker compose -f compose.yml -f compose.deploy.yml build
|
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
|
docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -118,7 +109,7 @@ The `compose.deploy.yml` file adds the deployment settings to the shared configu
|
|||||||
|
|
||||||
You can use GitHub Actions to deploy your project automatically. 😎
|
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. 🚀
|
The included `deploy.yml` workflow deploys the application whenever changes are pushed to `master`, including when a pull request is merged. 🚀
|
||||||
|
|
||||||
### Install GitHub Actions Runner
|
### Install GitHub Actions Runner
|
||||||
|
|
||||||
@@ -148,8 +139,6 @@ 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).
|
* [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.
|
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:
|
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:
|
||||||
@@ -192,54 +181,31 @@ cd /home/github/actions-runner
|
|||||||
|
|
||||||
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).
|
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
|
### Set Repository Secrets
|
||||||
|
|
||||||
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.
|
In your repository, go to **Settings** > **Secrets and variables** > **Actions** and add the following [repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository):
|
||||||
|
|
||||||
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 current Github Actions workflows expect these secrets:
|
|
||||||
|
|
||||||
* `DOMAIN`
|
* `DOMAIN`
|
||||||
* `EMAILS_FROM_EMAIL`
|
|
||||||
* `FIRST_SUPERUSER`
|
* `FIRST_SUPERUSER`
|
||||||
* `FIRST_SUPERUSER_PASSWORD`
|
* `FIRST_SUPERUSER_PASSWORD`
|
||||||
* `POSTGRES_PASSWORD`
|
* `POSTGRES_PASSWORD`
|
||||||
* `SECRET_KEY`
|
* `SECRET_KEY`
|
||||||
* `LATEST_CHANGES`
|
|
||||||
* `SMOKESHOW_AUTH_KEY`
|
|
||||||
|
|
||||||
## GitHub Action Deployment Workflows
|
To enable emails, configure these additional secrets with the values from your email provider:
|
||||||
|
|
||||||
There are GitHub Action workflows in the `.github/workflows` directory already configured for deploying to the environments (GitHub Actions runners with the labels):
|
* `SMTP_HOST`
|
||||||
|
* `SMTP_USER`
|
||||||
|
* `SMTP_PASSWORD`
|
||||||
|
* `EMAILS_FROM_EMAIL`
|
||||||
|
|
||||||
* `staging`: after pushing (or merging) to the branch `master`.
|
To enable Sentry, configure the `SENTRY_DSN` secret.
|
||||||
* `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.
|
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
Replace `fastapi-project.example.com` with your domain.
|
Replace `fastapi-project.example.com` with your domain.
|
||||||
|
|
||||||
### Production
|
|
||||||
|
|
||||||
Application (frontend and API): `https://fastapi-project.example.com`
|
Application (frontend and API): `https://fastapi-project.example.com`
|
||||||
|
|
||||||
Interactive API docs: `https://fastapi-project.example.com/docs`
|
Interactive API docs: `https://fastapi-project.example.com/docs`
|
||||||
|
|
||||||
Adminer: `https://adminer.fastapi-project.example.com`
|
Adminer: `https://adminer.fastapi-project.example.com`
|
||||||
|
|
||||||
### Staging
|
|
||||||
|
|
||||||
Application (frontend and API): `https://staging.fastapi-project.example.com`
|
|
||||||
|
|
||||||
Interactive API docs: `https://staging.fastapi-project.example.com/docs`
|
|
||||||
|
|
||||||
Adminer: `https://adminer.staging.fastapi-project.example.com`
|
|
||||||
|
|||||||
+64
-100
@@ -1,14 +1,67 @@
|
|||||||
# FastAPI Project - Development
|
# FastAPI Project - Development
|
||||||
|
|
||||||
## Docker Compose
|
## Local Development
|
||||||
|
|
||||||
* Start the local stack with Docker Compose:
|
For local development, run PostgreSQL and Mailcatcher with Docker Compose, and run the FastAPI and Vite development servers locally.
|
||||||
|
|
||||||
|
Start the supporting services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose up -d db mailcatcher
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, from the `backend` directory, install the dependencies and prepare the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync
|
||||||
|
uv run bash scripts/prestart.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the FastAPI development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run fastapi dev
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal, from the project root, install the frontend dependencies and start the Vite development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Now you can open these URLs:
|
||||||
|
|
||||||
|
Frontend development server: <http://localhost:5173>
|
||||||
|
|
||||||
|
Backend API: <http://localhost:8000>
|
||||||
|
|
||||||
|
Automatic interactive API documentation with Swagger UI: <http://localhost:8000/docs>
|
||||||
|
|
||||||
|
Mailcatcher: <http://localhost:1080>
|
||||||
|
|
||||||
|
The frontend development server uses the backend at `http://localhost:8000`, as configured in `frontend/.env`.
|
||||||
|
|
||||||
|
### Frontend Served by FastAPI
|
||||||
|
|
||||||
|
Build the frontend from the `frontend` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build is written to `backend/app/frontend` and served by FastAPI at <http://localhost:8000>. Rebuild the frontend after making frontend changes.
|
||||||
|
|
||||||
|
## Full Stack with Docker Compose
|
||||||
|
|
||||||
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend bash scripts/prestart.sh
|
||||||
docker compose watch
|
docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
* Now you can open your browser and interact with these URLs:
|
Now you can open these URLs:
|
||||||
|
|
||||||
Application, with the frontend and API served by FastAPI: <http://localhost:8000>
|
Application, with the frontend and API served by FastAPI: <http://localhost:8000>
|
||||||
|
|
||||||
@@ -18,86 +71,15 @@ Adminer, database web administration: <http://localhost:8080>
|
|||||||
|
|
||||||
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
|
Traefik UI, to see how the routes are being handled by the proxy: <http://localhost:8090>
|
||||||
|
|
||||||
**Note**: The first time you start your stack, it might take a minute for it to be ready. While the backend waits for the database to be ready and configures everything. You can check the logs to monitor it.
|
Mailcatcher: <http://localhost:1080>
|
||||||
|
|
||||||
To check the logs, run (in another terminal):
|
Stop a locally running FastAPI server before starting the Compose backend because both use port `8000`.
|
||||||
|
|
||||||
```bash
|
**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.
|
||||||
docker compose logs
|
|
||||||
```
|
|
||||||
|
|
||||||
To check the logs of a specific service, add the name of the service, e.g.:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose logs backend
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mailcatcher
|
## Mailcatcher
|
||||||
|
|
||||||
Mailcatcher is a simple SMTP server that catches all emails sent by the backend during local development. Instead of sending real emails, they are captured and displayed in a web interface.
|
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>.
|
||||||
|
|
||||||
This is useful for:
|
|
||||||
|
|
||||||
* Testing email functionality during development
|
|
||||||
* Verifying email content and formatting
|
|
||||||
* Debugging email-related functionality without sending real emails
|
|
||||||
|
|
||||||
The backend is automatically configured to use Mailcatcher when running with Docker Compose locally (SMTP on port 1025). All captured emails can be viewed at <http://localhost:1080>.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
The Docker Compose files are configured so that each supporting service is available in a different port in `localhost`.
|
|
||||||
|
|
||||||
FastAPI serves the built frontend and the API as one application at `http://localhost:8000`. The API routes live under `/api`.
|
|
||||||
|
|
||||||
For frontend development with live reload, you can still run the local Vite development server separately.
|
|
||||||
|
|
||||||
Start the local frontend development server with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Or you could stop the `backend` Docker Compose service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose stop backend
|
|
||||||
```
|
|
||||||
|
|
||||||
And then you can run the local development server for the backend:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
fastapi dev app/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
## Docker Compose files and env vars
|
||||||
|
|
||||||
@@ -107,9 +89,7 @@ And there's also a `compose.override.yml` with overrides for development, for ex
|
|||||||
|
|
||||||
The `compose.deploy.yml` file contains the deployment-specific settings, including HTTPS and automatic certificate handling. It is explicitly combined with `compose.yml` when deploying the application.
|
The `compose.deploy.yml` file contains the deployment-specific settings, including HTTPS and automatic certificate handling. It is explicitly combined with `compose.yml` when deploying the application.
|
||||||
|
|
||||||
These Docker Compose files use the `.env` file containing configurations to be injected as environment variables in the containers.
|
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:
|
After changing variables, make sure you restart the stack:
|
||||||
|
|
||||||
@@ -119,11 +99,11 @@ docker compose watch
|
|||||||
|
|
||||||
## The .env file
|
## The .env file
|
||||||
|
|
||||||
The `.env` file is the one that contains all your configurations, generated keys and passwords, etc.
|
The `.env` file contains the shared local defaults, generated keys, passwords, and other configuration. Its hostnames use `localhost` for processes running on your machine. Docker Compose overrides hostnames such as the database and SMTP server with their Compose service names.
|
||||||
|
|
||||||
Depending on your workflow, you could want to exclude it from Git, for example if your project is public. In that case, you would have to make sure to set up a way for your CI tools to obtain it while building or deploying your project.
|
Depending on your workflow, you could want to exclude it from Git, for example if your project is public. In that case, you would have to make sure to set up a way for your CI tools to obtain it while building or deploying your project.
|
||||||
|
|
||||||
One way to do it could be to add each environment variable to your CI/CD system, and updating the `compose.yml` file to read that specific env var instead of reading the `.env` file.
|
One way to do it could be to add each environment variable to your CI/CD system.
|
||||||
|
|
||||||
## Pre-commits and code linting
|
## Pre-commits and code linting
|
||||||
|
|
||||||
@@ -176,7 +156,7 @@ biome check..............................................................Passed
|
|||||||
|
|
||||||
## URLs
|
## 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
|
### Development URLs
|
||||||
|
|
||||||
@@ -193,19 +173,3 @@ Adminer: <http://localhost:8080>
|
|||||||
Traefik UI: <http://localhost:8090>
|
Traefik UI: <http://localhost:8090>
|
||||||
|
|
||||||
MailCatcher: <http://localhost:1080>
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
+6
-12
@@ -15,7 +15,9 @@ bun run dev
|
|||||||
|
|
||||||
* Then open your browser at http://localhost:5173/.
|
* Then open your browser at http://localhost:5173/.
|
||||||
|
|
||||||
Notice that this live server is not running inside Docker, it's for local development, and that is the recommended workflow. Once you are happy with your frontend, you can build the backend Docker image and start it, to test the production-like setup where FastAPI serves the built frontend at `http://localhost:8000`.
|
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`.
|
||||||
|
|
||||||
Check the file `package.json` to see other available options.
|
Check the file `package.json` to see other available options.
|
||||||
|
|
||||||
@@ -33,19 +35,10 @@ If you are developing an API-only app and want to remove the frontend, you can d
|
|||||||
|
|
||||||
Done, you have a frontend-less (api-only) app. 🤓
|
Done, you have a frontend-less (api-only) app. 🤓
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
If you want, you can also remove the `FRONTEND_HOST` environment variable from:
|
|
||||||
|
|
||||||
* `.env`
|
|
||||||
|
|
||||||
But it would be only to clean them up, leaving them won't really have any effect either way.
|
|
||||||
|
|
||||||
## Generate Client
|
## Generate Client
|
||||||
|
|
||||||
### Automatically
|
### Automatically
|
||||||
|
|
||||||
* Activate the backend virtual environment.
|
|
||||||
* From the top level project directory, run the script:
|
* From the top level project directory, run the script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -56,9 +49,9 @@ bash ./scripts/generate-client.sh
|
|||||||
|
|
||||||
### Manually
|
### Manually
|
||||||
|
|
||||||
* Start the Docker Compose stack.
|
* Make sure the backend is running.
|
||||||
|
|
||||||
* Download the OpenAPI JSON file from `http://localhost/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
* Download the OpenAPI JSON file from `http://localhost:8000/api/v1/openapi.json` and copy it to a new file `openapi.json` at the root of the `frontend` directory.
|
||||||
|
|
||||||
* To generate the frontend client, run:
|
* To generate the frontend client, run:
|
||||||
|
|
||||||
@@ -96,6 +89,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:
|
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
|
```bash
|
||||||
|
docker compose run --rm backend bash scripts/prestart.sh
|
||||||
docker compose up -d --wait backend
|
docker compose up -d --wait backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+15
-3
@@ -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" } } },
|
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"preset": "recommended",
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noExplicitAny": "off",
|
"noExplicitAny": "off",
|
||||||
"noArrayIndexKey": "off"
|
"noArrayIndexKey": "off"
|
||||||
@@ -42,5 +42,17 @@
|
|||||||
"parser": {
|
"parser": {
|
||||||
"tailwindDirectives": true
|
"tailwindDirectives": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["**/*.svg"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"a11y": {
|
||||||
|
"noSvgWithoutTitle": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-30
@@ -13,54 +13,54 @@
|
|||||||
"test:ui": "bunx playwright test --ui"
|
"test:ui": "bunx playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.7.1",
|
||||||
"@radix-ui/react-avatar": "^1.1.12",
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.3.4",
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
"@radix-ui/react-dialog": "^1.1.16",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.4.0",
|
"@radix-ui/react-radio-group": "^1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.11",
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
"@radix-ui/react-select": "^2.3.0",
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.9",
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.14",
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
"@radix-ui/react-tooltip": "^1.2.9",
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"@tanstack/react-query-devtools": "^5.101.0",
|
"@tanstack/react-query-devtools": "^5.101.4",
|
||||||
"@tanstack/react-router": "^1.170.15",
|
"@tanstack/react-router": "^1.170.18",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"axios": "1.18.0",
|
"axios": "1.18.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.28.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.8",
|
||||||
"react-error-boundary": "^6.1.2",
|
"react-error-boundary": "^6.1.2",
|
||||||
"react-hook-form": "^7.78.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.16",
|
"@biomejs/biome": "^2.5.6",
|
||||||
"@hey-api/openapi-ts": "0.97.3",
|
"@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-devtools": "^1.167.0",
|
||||||
"@tanstack/router-plugin": "^1.168.18",
|
"@tanstack/router-plugin": "^1.168.23",
|
||||||
"@types/node": "^25.9.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.4",
|
||||||
"@vitejs/plugin-react-swc": "^4.3.1",
|
"@vitejs/plugin-react-swc": "^4.3.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16"
|
"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.
|
// 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 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 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 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 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({
|
const LayoutRoute = LayoutRouteImport.update({
|
||||||
id: '/signup',
|
id: '/_layout',
|
||||||
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',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@@ -39,8 +28,19 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutRoute = LayoutRouteImport.update({
|
const RecoverPasswordRoute = RecoverPasswordRouteImport.update({
|
||||||
id: '/_layout',
|
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,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||||
@@ -48,9 +48,9 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
||||||
id: '/settings',
|
id: '/admin',
|
||||||
path: '/settings',
|
path: '/admin',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
||||||
@@ -58,9 +58,9 @@ const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
|||||||
path: '/items',
|
path: '/items',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
||||||
id: '/admin',
|
id: '/settings',
|
||||||
path: '/admin',
|
path: '/settings',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
@@ -140,25 +140,11 @@ export interface RootRouteChildren {
|
|||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/signup': {
|
'/_layout': {
|
||||||
id: '/signup'
|
id: '/_layout'
|
||||||
path: '/signup'
|
path: ''
|
||||||
fullPath: '/signup'
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof SignupRouteImport
|
preLoaderRoute: typeof LayoutRouteImport
|
||||||
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
|
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
@@ -168,11 +154,25 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_layout': {
|
'/recover-password': {
|
||||||
id: '/_layout'
|
id: '/recover-password'
|
||||||
path: ''
|
path: '/recover-password'
|
||||||
fullPath: '/'
|
fullPath: '/recover-password'
|
||||||
preLoaderRoute: typeof LayoutRouteImport
|
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
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_layout/': {
|
'/_layout/': {
|
||||||
@@ -182,11 +182,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutIndexRouteImport
|
preLoaderRoute: typeof LayoutIndexRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/settings': {
|
'/_layout/admin': {
|
||||||
id: '/_layout/settings'
|
id: '/_layout/admin'
|
||||||
path: '/settings'
|
path: '/admin'
|
||||||
fullPath: '/settings'
|
fullPath: '/admin'
|
||||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/items': {
|
'/_layout/items': {
|
||||||
@@ -196,11 +196,11 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutItemsRouteImport
|
preLoaderRoute: typeof LayoutItemsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
'/_layout/admin': {
|
'/_layout/settings': {
|
||||||
id: '/_layout/admin'
|
id: '/_layout/settings'
|
||||||
path: '/admin'
|
path: '/settings'
|
||||||
fullPath: '/admin'
|
fullPath: '/settings'
|
||||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,15 +79,16 @@ test("Sign up with existing email", async ({ page }) => {
|
|||||||
|
|
||||||
await fillForm(page, fullName, email, password, password)
|
await fillForm(page, fullName, email, password, password)
|
||||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||||
|
await expect(page).toHaveURL(/\/login$/)
|
||||||
|
|
||||||
await page.goto("/signup")
|
await page.goto("/signup")
|
||||||
|
|
||||||
await fillForm(page, fullName, email, password, password)
|
await fillForm(page, fullName, email, password, password)
|
||||||
await page.getByRole("button", { name: "Sign Up" }).click()
|
await page.getByRole("button", { name: "Sign Up" }).click()
|
||||||
|
|
||||||
await page
|
await expect(
|
||||||
.getByText("The user with this email already exists in the system")
|
page.getByText("The user with this email already exists in the system"),
|
||||||
.click()
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Sign up with weak password", async ({ page }) => {
|
test("Sign up with weak password", async ({ page }) => {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"baseUrl": ".",
|
"types": ["node"],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(import.meta.dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
|||||||
+5
-2
@@ -2,12 +2,15 @@
|
|||||||
"name": "fastapi-full-stack-template",
|
"name": "fastapi-full-stack-template",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"frontend"
|
"frontend",
|
||||||
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --filter frontend dev",
|
"dev": "bun run --filter frontend dev",
|
||||||
"lint": "bun run --filter frontend lint",
|
"lint": "bun run --filter frontend lint",
|
||||||
"test": "bun run --filter frontend test",
|
"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]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"prek>=0.4.11,<1.0.0",
|
"prek>=0.4.11,<1.0.0",
|
||||||
|
"typer>=0.20.0,<1.0.0",
|
||||||
"zizmor>=1.28.0",
|
"zizmor>=1.28.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+38
-4
@@ -2,13 +2,43 @@
|
|||||||
|
|
||||||
## Latest Changes
|
## Latest Changes
|
||||||
|
|
||||||
### Features
|
## 0.11.1 (2026-08-11)
|
||||||
|
|
||||||
* 🔧 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).
|
|
||||||
|
|
||||||
### Refactors
|
### 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).
|
||||||
|
* ♻️ Simplify environment defaults. PR [#2415](https://github.com/fastapi/full-stack-fastapi-template/pull/2415) by [@tiangolo](https://github.com/tiangolo).
|
||||||
|
* ♻️ Simplify local development workflow. PR [#2414](https://github.com/fastapi/full-stack-fastapi-template/pull/2414) by [@tiangolo](https://github.com/tiangolo).
|
||||||
* ♻️ Simplify environment configuration. PR [#2413](https://github.com/fastapi/full-stack-fastapi-template/pull/2413) by [@tiangolo](https://github.com/tiangolo).
|
* ♻️ Simplify environment configuration. PR [#2413](https://github.com/fastapi/full-stack-fastapi-template/pull/2413) by [@tiangolo](https://github.com/tiangolo).
|
||||||
* ♻️ Simplify Traefik deployment, make it part of the stack. PR [#2412](https://github.com/fastapi/full-stack-fastapi-template/pull/2412) by [@tiangolo](https://github.com/tiangolo).
|
* ♻️ Simplify Traefik deployment, make it part of the stack. PR [#2412](https://github.com/fastapi/full-stack-fastapi-template/pull/2412) by [@tiangolo](https://github.com/tiangolo).
|
||||||
* ♻️ Serve frontend from FastAPI. PR [#2393](https://github.com/fastapi/full-stack-fastapi-template/pull/2393) by [@tiangolo](https://github.com/tiangolo).
|
* ♻️ Serve frontend from FastAPI. PR [#2393](https://github.com/fastapi/full-stack-fastapi-template/pull/2393) by [@tiangolo](https://github.com/tiangolo).
|
||||||
@@ -25,6 +55,8 @@
|
|||||||
|
|
||||||
### Docs
|
### 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).
|
* 📝 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).
|
* ✏️ 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).
|
||||||
* 📝 Update security policy. PR [#2297](https://github.com/fastapi/full-stack-fastapi-template/pull/2297) by [@tiangolo](https://github.com/tiangolo).
|
* 📝 Update security policy. PR [#2297](https://github.com/fastapi/full-stack-fastapi-template/pull/2297) by [@tiangolo](https://github.com/tiangolo).
|
||||||
@@ -32,6 +64,8 @@
|
|||||||
|
|
||||||
### Internal
|
### 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 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 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).
|
* 👷 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
|
fi
|
||||||
|
|
||||||
docker-compose build
|
docker-compose build
|
||||||
|
docker-compose run --rm backend bash scripts/prestart.sh
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
docker-compose exec -T backend bash scripts/tests-start.sh "$@"
|
docker-compose exec -T backend bash scripts/tests-start.sh "$@"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ set -x
|
|||||||
|
|
||||||
docker compose build
|
docker compose build
|
||||||
docker compose down -v --remove-orphans # Remove possibly previous broken stacks left hanging after an error
|
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 up -d
|
||||||
docker compose exec -T backend bash scripts/tests-start.sh "$@"
|
docker compose exec -T backend bash scripts/tests-start.sh "$@"
|
||||||
docker compose down -v --remove-orphans
|
docker compose down -v --remove-orphans
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ members = [
|
|||||||
[manifest.dependency-groups]
|
[manifest.dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "prek", specifier = ">=0.4.11,<1.0.0" },
|
{ name = "prek", specifier = ">=0.4.11,<1.0.0" },
|
||||||
|
{ name = "typer", specifier = ">=0.20.0,<1.0.0" },
|
||||||
{ name = "zizmor", specifier = ">=1.28.0" },
|
{ name = "zizmor", specifier = ">=1.28.0" },
|
||||||
]
|
]
|
||||||
github-actions = [{ name = "smokeshow", specifier = ">=0.5.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/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/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/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/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/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/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" },
|
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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" },
|
{ 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/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/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/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/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/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/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" },
|
{ 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