mirror of
https://github.com/fastapi/full-stack-fastapi-template.git
synced 2026-11-03 20:36:27 +00:00
Compare commits
39 Commits
6c2122e37d
...
0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 84c5a9e11a | |||
| 98fb606b57 | |||
| de2ed05b10 | |||
| ec86250a21 | |||
| 7f11815e43 | |||
| a2d2d18108 | |||
| 2d8fb3e4de | |||
| ddb3c34f19 | |||
| eae2d6701d | |||
| 6b9fa4113c | |||
| b55b3122ab | |||
| 6dbb2e8256 | |||
| 6d75b2d132 | |||
| 32ab6dd022 | |||
| cf1548a983 | |||
| c8f9069168 | |||
| 512e496e1f | |||
| fd0ca44d77 | |||
| af791805ac | |||
| 9a218cd74a | |||
| d85803bf64 | |||
| 781b283849 | |||
| c291285975 | |||
| b1abd91ba7 | |||
| e95e501951 | |||
| 111c5fad66 | |||
| 9d541082a3 | |||
| 8893c6807a | |||
| 1c5dd70308 | |||
| b5a664d510 | |||
| 23b0ba9bad | |||
| 74cf9ed832 | |||
| 4256e83fec | |||
| 85cd14d474 | |||
| 5069df32ea | |||
| f2035ef732 | |||
| b0ccf1e80e | |||
| de53c8fd35 | |||
| 375af70ee2 |
@@ -1 +0,0 @@
|
|||||||
{{ _copier_answers|to_json -}}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
import json
|
|
||||||
|
|
||||||
# Update the .env file with the answers from the .copier-answers.yml file
|
|
||||||
# without using Jinja2 templates in the .env file, this way the code works as is
|
|
||||||
# without needing Copier, but if Copier is used, the .env file will be updated
|
|
||||||
root_path = Path(__file__).parent.parent
|
|
||||||
answers_path = Path(__file__).parent / ".copier-answers.yml"
|
|
||||||
answers = json.loads(answers_path.read_text())
|
|
||||||
env_path = root_path / ".env"
|
|
||||||
env_content = env_path.read_text()
|
|
||||||
# Keep deployment provider settings from replacing local development defaults.
|
|
||||||
env_answer_keys = {
|
|
||||||
"emails_from_email",
|
|
||||||
"first_superuser",
|
|
||||||
"first_superuser_password",
|
|
||||||
"postgres_password",
|
|
||||||
"project_name",
|
|
||||||
"secret_key",
|
|
||||||
}
|
|
||||||
lines = []
|
|
||||||
for line in env_content.splitlines():
|
|
||||||
for key, value in answers.items():
|
|
||||||
if key not in env_answer_keys:
|
|
||||||
continue
|
|
||||||
upper_key = key.upper()
|
|
||||||
if line.startswith(f"{upper_key}="):
|
|
||||||
if " " in value:
|
|
||||||
content = f"{upper_key}={value!r}"
|
|
||||||
else:
|
|
||||||
content = f"{upper_key}={value}"
|
|
||||||
new_line = line.replace(line, content)
|
|
||||||
lines.append(new_line)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
env_path.write_text("\n".join(lines))
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
.git
|
.git
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/.venv
|
**/.venv
|
||||||
backend/app/frontend
|
|
||||||
backend/htmlcov
|
backend/htmlcov
|
||||||
frontend/blob-report
|
frontend/blob-report
|
||||||
frontend/dist
|
frontend/dist
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Enable local-only development behavior
|
# Enable development behavior for commands that import the app directly
|
||||||
ENVIRONMENT=local
|
FASTAPI_ENV=development
|
||||||
|
|
||||||
PROJECT_NAME="Full Stack FastAPI Project"
|
PROJECT_NAME="Full Stack FastAPI Project"
|
||||||
|
|
||||||
@@ -14,7 +14,5 @@ SMTP_TLS=False
|
|||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
|
|
||||||
# Postgres
|
# Postgres
|
||||||
POSTGRES_SERVER=localhost
|
|
||||||
POSTGRES_DB=app
|
|
||||||
POSTGRES_USER=postgres
|
|
||||||
POSTGRES_PASSWORD=changethis
|
POSTGRES_PASSWORD=changethis
|
||||||
|
DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@localhost:5432/app
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
!backend/app/frontend/
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
docs:
|
|
||||||
- all:
|
|
||||||
- changed-files:
|
|
||||||
- any-glob-to-any-file:
|
|
||||||
- '**/*.md'
|
|
||||||
- all-globs-to-all-files:
|
|
||||||
- '!frontend/**'
|
|
||||||
- '!backend/**'
|
|
||||||
- '!.github/**'
|
|
||||||
- '!scripts/**'
|
|
||||||
- '!.gitignore'
|
|
||||||
- '!.pre-commit-config.yaml'
|
|
||||||
|
|
||||||
internal:
|
|
||||||
- all:
|
|
||||||
- changed-files:
|
|
||||||
- any-glob-to-any-file:
|
|
||||||
- .github/**
|
|
||||||
- scripts/**
|
|
||||||
- .gitignore
|
|
||||||
- .pre-commit-config.yaml
|
|
||||||
- all-globs-to-all-files:
|
|
||||||
- '!./**/*.md'
|
|
||||||
- '!frontend/**'
|
|
||||||
- '!backend/**'
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
auto-labels:
|
||||||
|
docs:
|
||||||
|
- exclude: frontend/**
|
||||||
|
- exclude: backend/**
|
||||||
|
- exclude: .github/**
|
||||||
|
- exclude: scripts/**
|
||||||
|
- '**/*.md'
|
||||||
|
internal:
|
||||||
|
- .github/**
|
||||||
|
- scripts/**
|
||||||
|
- .gitignore
|
||||||
|
- .pre-commit-config.yaml
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
workflows:
|
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)"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy with Docker Compose
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-docker-compose
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Do not deploy the template repository, only projects created from it
|
||||||
|
if: github.repository != 'fastapi/full-stack-fastapi-template'
|
||||||
|
runs-on: self-hosted
|
||||||
|
timeout-minutes: 30
|
||||||
|
env:
|
||||||
|
DOMAIN: ${{ vars.DOMAIN }}
|
||||||
|
PROJECT_NAME: ${{ vars.PROJECT_NAME }}
|
||||||
|
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||||
|
FIRST_SUPERUSER: ${{ vars.FIRST_SUPERUSER }}
|
||||||
|
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||||
|
SMTP_HOST: ${{ vars.SMTP_HOST }}
|
||||||
|
SMTP_USER: ${{ vars.SMTP_USER }}
|
||||||
|
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
||||||
|
EMAILS_FROM_EMAIL: ${{ vars.EMAILS_FROM_EMAIL }}
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
SENTRY_DSN: ${{ vars.SENTRY_DSN }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Build
|
||||||
|
run: docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
- name: Prepare database
|
||||||
|
run: docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
|
- name: Start application
|
||||||
|
run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
name: Deploy to Production
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types:
|
|
||||||
- published
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
environment: production
|
|
||||||
# Do not deploy in the main repository, only in user projects
|
|
||||||
if: github.repository_owner != 'fastapi'
|
|
||||||
runs-on:
|
|
||||||
- self-hosted
|
|
||||||
- production
|
|
||||||
env:
|
|
||||||
DOMAIN: ${{ secrets.DOMAIN }}
|
|
||||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
|
||||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
|
||||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
|
||||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
|
||||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
|
||||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
|
||||||
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
|
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
|
||||||
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
name: Deploy to Staging
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
environment: staging
|
|
||||||
# Do not deploy in the main repository, only in user projects
|
|
||||||
if: github.repository_owner != 'fastapi'
|
|
||||||
runs-on:
|
|
||||||
- self-hosted
|
|
||||||
- staging
|
|
||||||
env:
|
|
||||||
DOMAIN: ${{ secrets.DOMAIN }}
|
|
||||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
|
||||||
FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
|
|
||||||
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
|
||||||
SMTP_HOST: ${{ secrets.SMTP_HOST }}
|
|
||||||
SMTP_USER: ${{ secrets.SMTP_USER }}
|
|
||||||
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
|
|
||||||
EMAILS_FROM_EMAIL: ${{ secrets.EMAILS_FROM_EMAIL }}
|
|
||||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
- run: docker compose -f compose.yml -f compose.deploy.yml build
|
|
||||||
- run: docker compose -f compose.yml -f compose.deploy.yml up -d
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- release-notes.md
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
# Do not deploy the template repository, only projects created from it
|
||||||
|
if: github.repository != 'fastapi/full-stack-fastapi-template'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Set up Bun
|
||||||
|
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.12
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: bun ci
|
||||||
|
- name: Build frontend
|
||||||
|
run: bun run --filter frontend build
|
||||||
|
env:
|
||||||
|
# Override frontend/.env so the deployed frontend uses the same origin as the API
|
||||||
|
VITE_API_URL: ""
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
|
with:
|
||||||
|
version: "0.11.18"
|
||||||
|
- name: Prepare database
|
||||||
|
run: uv run bash scripts/prestart.sh
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
|
PROJECT_NAME: ${{ vars.PROJECT_NAME }}
|
||||||
|
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||||
|
FIRST_SUPERUSER: ${{ vars.FIRST_SUPERUSER }}
|
||||||
|
FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
|
||||||
|
- name: Deploy to FastAPI Cloud
|
||||||
|
run: uv run fastapi deploy
|
||||||
|
env:
|
||||||
|
FASTAPI_CLOUD_TOKEN: ${{ secrets.FASTAPI_CLOUD_TOKEN }}
|
||||||
|
FASTAPI_CLOUD_APP_ID: ${{ secrets.FASTAPI_CLOUD_APP_ID }}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
name: Labels
|
|
||||||
on:
|
|
||||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
# For label-checker
|
|
||||||
- labeled
|
|
||||||
- unlabeled
|
|
||||||
|
|
||||||
permissions: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
labeler:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
|
||||||
if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }}
|
|
||||||
- run: echo "Done adding labels"
|
|
||||||
# Run this after labeler applied labels
|
|
||||||
check-labels:
|
|
||||||
needs:
|
|
||||||
- labeler
|
|
||||||
permissions:
|
|
||||||
pull-requests: read
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
steps:
|
|
||||||
- uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
|
|
||||||
with:
|
|
||||||
one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal
|
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
@@ -78,6 +78,7 @@ jobs:
|
|||||||
- run: bash scripts/generate-client.sh
|
- run: 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/.*
|
||||||
|
|||||||
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"
|
||||||
|
|||||||
@@ -11,20 +11,21 @@
|
|||||||
- 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
|
- 🔍 [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management.
|
||||||
- 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database.
|
- 💾 [PostgreSQL](https://www.postgresql.org) as the SQL database.
|
||||||
- 🚀 [React](https://react.dev) for the frontend.
|
- 🚀 [React](https://react.dev) for the frontend.
|
||||||
- 🧩 Built into the backend image and served by FastAPI on the same domain as the API.
|
- 🧩 Built into the backend application and served by FastAPI on the same domain as the API.
|
||||||
- 💃 Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack.
|
- 💃 Using TypeScript, hooks, [Vite](https://vitejs.dev), and other parts of a modern frontend stack.
|
||||||
- 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components.
|
- 🎨 [Tailwind CSS](https://tailwindcss.com) and [shadcn/ui](https://ui.shadcn.com) for the frontend components.
|
||||||
- 🤖 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 local services and deployment.
|
- ☁️ [FastAPI Cloud](https://fastapicloud.com) for deployment.
|
||||||
|
- 🐋 [Docker Compose](https://www.docker.com) for local services and self-hosted deployment.
|
||||||
|
- 📞 [Traefik](https://traefik.io) as a reverse proxy with automatic HTTPS.
|
||||||
- 🔒 Secure password hashing by default.
|
- 🔒 Secure password hashing by default.
|
||||||
- 🔑 JWT (JSON Web Token) authentication.
|
- 🔑 JWT (JSON Web Token) authentication.
|
||||||
- 📫 Email based password recovery.
|
- 📫 Email based password recovery.
|
||||||
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
- 📬 [Mailcatcher](https://mailcatcher.me) for local email testing during development.
|
||||||
- ✅ Tests with [Pytest](https://pytest.org).
|
- ✅ Tests with [Pytest](https://pytest.org).
|
||||||
- 📞 [Traefik](https://traefik.io) as a reverse proxy / load balancer.
|
- 🚢 Deployment with FastAPI Cloud or self-hosting with Docker Compose and Traefik.
|
||||||
- 🚢 Deployment instructions using Docker Compose with automatic HTTPS provided by Traefik.
|
|
||||||
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
- 🏭 CI (continuous integration) and CD (continuous deployment) based on GitHub Actions.
|
||||||
|
|
||||||
### Dashboard Login
|
### Dashboard Login
|
||||||
@@ -123,17 +124,7 @@ git merge --continue
|
|||||||
|
|
||||||
### Configure
|
### Configure
|
||||||
|
|
||||||
You can then update configs in the `.env` files to customize your configurations.
|
You can update the local settings in the `.env` file. Before deploying, configure the environment variables and secrets described in the [FastAPI Cloud deployment guide](./deployment.md) or the [Docker Compose deployment guide](./deployment-docker-compose.md). Never deploy with the default `changethis` values.
|
||||||
|
|
||||||
Before deploying it, make sure you change at least the values for:
|
|
||||||
|
|
||||||
- `SECRET_KEY`
|
|
||||||
- `FIRST_SUPERUSER_PASSWORD`
|
|
||||||
- `POSTGRES_PASSWORD`
|
|
||||||
|
|
||||||
You can (and should) pass these as environment variables from secrets.
|
|
||||||
|
|
||||||
Read the [deployment.md](./deployment.md) docs for more details.
|
|
||||||
|
|
||||||
### Generate Secret Keys
|
### Generate Secret Keys
|
||||||
|
|
||||||
@@ -147,61 +138,6 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
||||||
|
|
||||||
## How To Use It - Alternative With Copier
|
|
||||||
|
|
||||||
This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
|
|
||||||
|
|
||||||
It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
|
|
||||||
|
|
||||||
### Install Copier
|
|
||||||
|
|
||||||
You can install Copier with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx install copier
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
|
|
||||||
|
|
||||||
### Generate a Project With Copier
|
|
||||||
|
|
||||||
Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
|
|
||||||
|
|
||||||
Go to the directory that will be the parent of your project, and run the command with your project's name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have `pipx` and you didn't install `copier`, you can run it directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
|
|
||||||
|
|
||||||
### Input Variables
|
|
||||||
|
|
||||||
Copier will ask you for some data, you might want to have at hand before generating the project.
|
|
||||||
|
|
||||||
But don't worry, you can just update any of that in the `.env` files afterwards.
|
|
||||||
|
|
||||||
The input variables, with their default values (some auto generated) are:
|
|
||||||
|
|
||||||
- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
|
|
||||||
- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
|
|
||||||
- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
|
|
||||||
- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
|
|
||||||
- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
|
|
||||||
- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
|
|
||||||
|
|
||||||
## Backend Development
|
## Backend Development
|
||||||
|
|
||||||
Backend docs: [backend/README.md](./backend/README.md).
|
Backend docs: [backend/README.md](./backend/README.md).
|
||||||
@@ -214,11 +150,13 @@ Frontend docs: [frontend/README.md](./frontend/README.md).
|
|||||||
|
|
||||||
Deployment docs: [deployment.md](./deployment.md).
|
Deployment docs: [deployment.md](./deployment.md).
|
||||||
|
|
||||||
|
Docker Compose deployment: [deployment-docker-compose.md](./deployment-docker-compose.md).
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
General development docs: [development.md](./development.md).
|
General development docs: [development.md](./development.md).
|
||||||
|
|
||||||
This includes the local FastAPI and Vite workflow, Docker Compose services, custom local domains, `.env` configuration, and more.
|
This includes the local FastAPI and Vite workflow, Docker Compose services, `.env` configuration, and more.
|
||||||
|
|
||||||
## Release Notes
|
## Release Notes
|
||||||
|
|
||||||
|
|||||||
+17
-18
@@ -27,21 +27,7 @@ The API is available at `http://localhost:8000`, with automatic interactive docs
|
|||||||
|
|
||||||
## General Workflow
|
## General Workflow
|
||||||
|
|
||||||
By default, the dependencies are managed with [uv](https://docs.astral.sh/uv/), go there and install it.
|
Run backend commands from `./backend/` with `uv run`. Make sure your editor uses the Python interpreter at `.venv/bin/python` in the project root.
|
||||||
|
|
||||||
From `./backend/` you can install all the dependencies with:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
Then you can activate the virtual environment with:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ source .venv/bin/activate
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure your editor is using the correct Python virtual environment, with the interpreter at `backend/.venv/bin/python`.
|
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
@@ -56,6 +42,7 @@ The setup is also already configured so you can run the tests through the VS Cod
|
|||||||
To run the backend and built frontend in Docker Compose:
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
```console
|
```console
|
||||||
|
$ docker compose run --rm backend bash scripts/prestart.sh
|
||||||
$ docker compose watch
|
$ docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -139,8 +126,20 @@ If you don't want to start with the default models and want to remove them / mod
|
|||||||
|
|
||||||
## Email Templates
|
## 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"] | None = None
|
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
+4
-6
@@ -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:
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.all_cors_origins,
|
allow_origins=[settings.FRONTEND_HOST],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ services:
|
|||||||
# 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:
|
||||||
|
|||||||
+4
-13
@@ -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,15 +63,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend/htmlcov:/app/backend/htmlcov
|
- ./backend/htmlcov:/app/backend/htmlcov
|
||||||
environment:
|
environment:
|
||||||
ENVIRONMENT: "local"
|
FASTAPI_ENV: "development"
|
||||||
SMTP_HOST: "mailcatcher"
|
SMTP_HOST: "mailcatcher"
|
||||||
SMTP_PORT: "1025"
|
SMTP_PORT: "1025"
|
||||||
SMTP_TLS: "false"
|
SMTP_TLS: "false"
|
||||||
|
|
||||||
prestart:
|
|
||||||
environment:
|
|
||||||
ENVIRONMENT: "local"
|
|
||||||
|
|
||||||
mailcatcher:
|
mailcatcher:
|
||||||
image: schickling/mailcatcher
|
image: schickling/mailcatcher
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+4
-24
@@ -20,7 +20,7 @@ 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
|
||||||
@@ -29,8 +29,7 @@ services:
|
|||||||
- app-db-data:/var/lib/postgresql
|
- app-db-data:/var/lib/postgresql
|
||||||
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
|
||||||
@@ -53,12 +52,8 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
prestart:
|
environment:
|
||||||
condition: service_completed_successfully
|
|
||||||
# The YAML anchor lets the prestart service reuse this environment mapping.
|
|
||||||
environment: &backend-environment
|
|
||||||
PROJECT_NAME: ${PROJECT_NAME:?Variable not set}
|
PROJECT_NAME: ${PROJECT_NAME:?Variable not set}
|
||||||
BACKEND_CORS_ORIGINS: ${BACKEND_CORS_ORIGINS:-}
|
|
||||||
SECRET_KEY: ${SECRET_KEY:?Variable not set}
|
SECRET_KEY: ${SECRET_KEY:?Variable not set}
|
||||||
FIRST_SUPERUSER: ${FIRST_SUPERUSER:?Variable not set}
|
FIRST_SUPERUSER: ${FIRST_SUPERUSER:?Variable not set}
|
||||||
FIRST_SUPERUSER_PASSWORD: ${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
FIRST_SUPERUSER_PASSWORD: ${FIRST_SUPERUSER_PASSWORD:?Variable not set}
|
||||||
@@ -66,10 +61,7 @@ services:
|
|||||||
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}
|
||||||
POSTGRES_SERVER: db
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:?Variable not set}@db:5432/app
|
||||||
POSTGRES_DB: ${POSTGRES_DB:?Variable not set}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:?Variable not set}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Variable not set}
|
|
||||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -92,17 +84,5 @@ services:
|
|||||||
- traefik.http.routers.backend-http.rule=Host(`${DOMAIN:-localhost}`)
|
- 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:
|
||||||
|
|||||||
-75
@@ -1,75 +0,0 @@
|
|||||||
project_name:
|
|
||||||
type: str
|
|
||||||
help: The name of the project, shown to API users (in .env)
|
|
||||||
default: FastAPI Project
|
|
||||||
|
|
||||||
secret_key:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The secret key for the project, used for security,
|
|
||||||
stored in .env, you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
first_superuser:
|
|
||||||
type: str
|
|
||||||
help: The email of the first superuser (in .env)
|
|
||||||
default: admin@example.com
|
|
||||||
|
|
||||||
first_superuser_password:
|
|
||||||
type: str
|
|
||||||
help: The password of the first superuser (in .env)
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
emails_from_email:
|
|
||||||
type: str
|
|
||||||
help: The email account to send emails from, you can set it later in .env
|
|
||||||
default: info@example.com
|
|
||||||
|
|
||||||
postgres_password:
|
|
||||||
type: str
|
|
||||||
help: |
|
|
||||||
'The password for the PostgreSQL database, stored in .env,
|
|
||||||
you can generate one with:
|
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
||||||
default: changethis
|
|
||||||
|
|
||||||
_exclude:
|
|
||||||
# Global
|
|
||||||
- .vscode
|
|
||||||
- .mypy_cache
|
|
||||||
# Python
|
|
||||||
- __pycache__
|
|
||||||
- app.egg-info
|
|
||||||
- "*.pyc"
|
|
||||||
- .mypy_cache
|
|
||||||
- .coverage
|
|
||||||
- htmlcov
|
|
||||||
- .cache
|
|
||||||
- .venv
|
|
||||||
# Frontend
|
|
||||||
# Logs
|
|
||||||
- logs
|
|
||||||
- "*.log"
|
|
||||||
- npm-debug.log*
|
|
||||||
- yarn-debug.log*
|
|
||||||
- yarn-error.log*
|
|
||||||
- pnpm-debug.log*
|
|
||||||
- lerna-debug.log*
|
|
||||||
- node_modules
|
|
||||||
- dist
|
|
||||||
- dist-ssr
|
|
||||||
- "*.local"
|
|
||||||
# Editor directories and files
|
|
||||||
- .idea
|
|
||||||
- .DS_Store
|
|
||||||
- "*.suo"
|
|
||||||
- "*.ntvs*"
|
|
||||||
- "*.njsproj"
|
|
||||||
- "*.sln"
|
|
||||||
- "*.sw?"
|
|
||||||
|
|
||||||
_answers_file: .copier/.copier-answers.yml
|
|
||||||
|
|
||||||
_tasks:
|
|
||||||
- ["{{ _copier_python }}", .copier/update_dotenv.py]
|
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# FastAPI Project - Docker Compose Deployment
|
||||||
|
|
||||||
|
You can deploy the project to your own remote server with Docker Compose. The deployment configuration includes Traefik to handle HTTPS and route incoming traffic to the application.
|
||||||
|
|
||||||
|
## Preparation
|
||||||
|
|
||||||
|
* Have a remote server ready and available.
|
||||||
|
* Configure DNS records pointing to the server for the application domain and any supporting service subdomains you want to expose, such as `fastapi-project.example.com` and `adminer.fastapi-project.example.com`.
|
||||||
|
* Install and configure [Docker](https://docs.docker.com/engine/install/) on the remote server (Docker Engine, not Docker Desktop).
|
||||||
|
|
||||||
|
## Copy the Code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -av --exclude=".git/" --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--filter=":- .gitignore"` option tells `rsync` to use the same ignore rules as Git, excluding files such as the Python virtual environment.
|
||||||
|
|
||||||
|
## Configure the Application
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Set the application domain, project name, and first superuser email:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DOMAIN=fastapi-project.example.com
|
||||||
|
export PROJECT_NAME="Full Stack FastAPI Project"
|
||||||
|
export FIRST_SUPERUSER=admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also configure these environment variables as needed:
|
||||||
|
|
||||||
|
* `SMTP_HOST`: The SMTP server host from your email provider.
|
||||||
|
* `SMTP_USER`: The SMTP server user.
|
||||||
|
* `EMAILS_FROM_EMAIL`: The email account used to send emails.
|
||||||
|
* `SENTRY_DSN`: The DSN for Sentry.
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
Generate and set secure values for the database password, token signing key, and first superuser password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
export FIRST_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||||
|
```
|
||||||
|
|
||||||
|
To use an authenticated email provider, also set `SMTP_PASSWORD`.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/code/app/
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml build
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml run --rm backend bash scripts/prestart.sh
|
||||||
|
docker compose -f compose.yml -f compose.deploy.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The `compose.deploy.yml` file adds HTTPS and automatic certificate handling to the shared `compose.yml` configuration. Explicitly listing both files excludes the local settings from `compose.override.yml`.
|
||||||
|
|
||||||
|
The backend Docker image builds the frontend, so the server does not need Bun or prebuilt frontend files.
|
||||||
|
|
||||||
|
## Deploy with GitHub Actions
|
||||||
|
|
||||||
|
The included `.github/workflows/deploy-docker-compose.yml` workflow runs the deployment commands on the server when manually triggered from GitHub Actions.
|
||||||
|
|
||||||
|
Use a self-hosted runner only for a repository whose contributors and workflow code you trust. GitHub recommends using self-hosted runners with private repositories because workflows execute directly on the runner machine.
|
||||||
|
|
||||||
|
### Configure Repository Variables and Secrets
|
||||||
|
|
||||||
|
In the repository, go to **Settings** > **Secrets and variables** > **Actions** and add these repository variables:
|
||||||
|
|
||||||
|
* `DOMAIN`
|
||||||
|
* `PROJECT_NAME`
|
||||||
|
* `FIRST_SUPERUSER`
|
||||||
|
|
||||||
|
To enable emails, add these optional repository variables:
|
||||||
|
|
||||||
|
* `SMTP_HOST`
|
||||||
|
* `SMTP_USER`
|
||||||
|
* `EMAILS_FROM_EMAIL`
|
||||||
|
|
||||||
|
To enable Sentry, add the optional `SENTRY_DSN` repository variable.
|
||||||
|
|
||||||
|
Add these repository secrets:
|
||||||
|
|
||||||
|
* `POSTGRES_PASSWORD`
|
||||||
|
* `SECRET_KEY`
|
||||||
|
* `FIRST_SUPERUSER_PASSWORD`
|
||||||
|
|
||||||
|
To use an authenticated email provider, add the optional `SMTP_PASSWORD` repository secret.
|
||||||
|
|
||||||
|
### Install a Self-Hosted Runner
|
||||||
|
|
||||||
|
On the server, create a dedicated user and grant it access to Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser github
|
||||||
|
sudo usermod -aG docker github
|
||||||
|
sudo su - github
|
||||||
|
```
|
||||||
|
|
||||||
|
In the GitHub repository, go to **Settings** > **Actions** > **Runners**, select **New self-hosted runner**, choose Linux, and follow the commands GitHub provides to download, configure, and register the runner. Install it in `/home/github/actions-runner`.
|
||||||
|
|
||||||
|
After registering the runner, exit the `github` user session and install the runner as a system service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
exit
|
||||||
|
cd /home/github/actions-runner
|
||||||
|
sudo ./svc.sh install github
|
||||||
|
sudo ./svc.sh start
|
||||||
|
sudo ./svc.sh status
|
||||||
|
```
|
||||||
|
|
||||||
|
See GitHub's guides for [adding a self-hosted runner](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/add-runners) and [configuring the runner as a service](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/configure-the-application?platform=linux).
|
||||||
|
|
||||||
|
### Run the Deployment
|
||||||
|
|
||||||
|
When the runner is online, open the repository's **Actions** tab, select **Deploy with Docker Compose**, and select **Run workflow**.
|
||||||
|
|
||||||
|
## URLs
|
||||||
|
|
||||||
|
Replace `fastapi-project.example.com` with your domain.
|
||||||
|
|
||||||
|
Application (frontend and API): `https://fastapi-project.example.com`
|
||||||
|
|
||||||
|
Interactive API docs: `https://fastapi-project.example.com/docs`
|
||||||
|
|
||||||
|
Adminer: `https://adminer.fastapi-project.example.com`
|
||||||
+51
-195
@@ -1,241 +1,97 @@
|
|||||||
# FastAPI Project - Deployment
|
# FastAPI Project - Deployment
|
||||||
|
|
||||||
You can deploy the project using Docker Compose to a remote server.
|
Deploy the project to [FastAPI Cloud](https://fastapicloud.com) with the included GitHub Actions workflow.
|
||||||
|
|
||||||
The deployment Docker Compose configuration includes Traefik to handle HTTPS and route incoming traffic to the application.
|
## Create the FastAPI Cloud Application
|
||||||
|
|
||||||
You can use CI/CD (continuous integration and continuous deployment) systems to deploy automatically, there are already configurations to do it with GitHub Actions.
|
Create an application in FastAPI Cloud and set its [Application Directory](https://fastapicloud.com/docs/builds-and-deployments/application-directory/) to `backend`.
|
||||||
|
|
||||||
But you have to configure a couple things first. 🤓
|
Connect a PostgreSQL database using the [Neon](https://fastapicloud.com/docs/integrations/neon-integration/) or [Supabase](https://fastapicloud.com/docs/integrations/supabase-integration/) integration. Both integrations configure a `DATABASE_URL` secret automatically. You can also configure `DATABASE_URL` manually for another PostgreSQL provider.
|
||||||
|
|
||||||
## Preparation
|
## Configure the Application
|
||||||
|
|
||||||
* Have a remote server ready and available. Use a separate server for each environment, for example one for staging and one for production.
|
### Environment Variables
|
||||||
* 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).
|
|
||||||
|
|
||||||
## Deploy the FastAPI Project
|
Add these required [environment variables](https://fastapicloud.com/docs/builds-and-deployments/environment-variables/) to the FastAPI Cloud application:
|
||||||
|
|
||||||
You can deploy your FastAPI project with Docker Compose.
|
* `PROJECT_NAME`: The name of the project, used in the API documentation and emails.
|
||||||
|
* `FIRST_SUPERUSER`: The email address of the first superuser.
|
||||||
|
* `FRONTEND_HOST`: The public URL of the application, such as the generated `https://your-app.fastapicloud.dev` URL or a custom domain.
|
||||||
|
|
||||||
**Note**: You might want to jump ahead to the section about Continuous Deployment with GitHub Actions.
|
To enable emails, add these optional environment variables with values from your email provider:
|
||||||
|
|
||||||
## Copy the Code
|
* `SMTP_HOST`
|
||||||
|
* `SMTP_USER`
|
||||||
|
* `EMAILS_FROM_EMAIL`
|
||||||
|
|
||||||
```bash
|
To enable Sentry, configure `SENTRY_DSN`.
|
||||||
rsync -av --exclude=".git/" --filter=":- .gitignore" ./ root@your-server.example.com:/root/code/app/
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `--filter=":- .gitignore"` tells `rsync` to use the same rules as git, ignore files ignored by git, like the Python virtual environment.
|
### Secrets
|
||||||
|
|
||||||
## Environment Variables
|
Add these required values and mark them as secrets:
|
||||||
|
|
||||||
You need to set some environment variables first.
|
* `SECRET_KEY`: A secret key used to sign security tokens.
|
||||||
|
* `FIRST_SUPERUSER_PASSWORD`: The password of the first superuser.
|
||||||
|
* `DATABASE_URL`: The PostgreSQL connection URL, configured automatically when using a database integration.
|
||||||
|
|
||||||
### Generate secret keys
|
To enable emails with an authenticated provider, add `SMTP_PASSWORD` as a secret.
|
||||||
|
|
||||||
Some environment variables in the `.env` file have a default value of `changethis`.
|
You can generate secure values for `SECRET_KEY` and `FIRST_SUPERUSER_PASSWORD` with:
|
||||||
|
|
||||||
You have to change them with a secret key, to generate secret keys you can run the following command:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy the content and use that as password / secret key. And run that again to generate another secure key.
|
## Configure Continuous Deployment
|
||||||
|
|
||||||
### Required Environment Variables
|
The included `.github/workflows/deploy.yml` workflow builds the frontend, prepares the database, and deploys the application whenever changes are pushed to `master`. You can also run it manually from the **Actions** tab.
|
||||||
|
|
||||||
Set the `DOMAIN` to your own domain, for example:
|
Log in to FastAPI Cloud and configure the [deploy token](https://fastapicloud.com/docs/advanced-features/deploy-tokens/) and application ID as GitHub repository secrets:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export DOMAIN=fastapi-project.example.com
|
uv run fastapi login
|
||||||
|
uv run fastapi cloud setup-ci --secrets-only --app-id <your-app-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
The deployment Docker Compose configuration also uses `DOMAIN` to set the public frontend URL used in links generated by the backend.
|
If the GitHub CLI is installed and authenticated, the command configures `FASTAPI_CLOUD_TOKEN` and `FASTAPI_CLOUD_APP_ID` automatically. Otherwise, it prints the values so you can add them in your repository under **Settings** > **Secrets and variables** > **Actions**.
|
||||||
|
|
||||||
Set the `POSTGRES_PASSWORD` to a secure value:
|
The workflow runs database migrations and creates the first superuser before deploying. In the repository's **Settings** > **Secrets and variables** > **Actions** page, add these repository variables:
|
||||||
|
|
||||||
```bash
|
* `PROJECT_NAME`
|
||||||
export POSTGRES_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `SECRET_KEY`, used to sign tokens, to a secure value:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
|
||||||
```
|
|
||||||
|
|
||||||
Set the `FIRST_SUPERUSER_PASSWORD` to a secure value:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export FIRST_SUPERUSER_PASSWORD="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
|
||||||
```
|
|
||||||
|
|
||||||
You can set several other environment variables:
|
|
||||||
|
|
||||||
* `PROJECT_NAME`: The name of the project, used in the API for the docs and emails.
|
|
||||||
* `BACKEND_CORS_ORIGINS`: A list of additional allowed CORS origins separated by commas. The frontend served by FastAPI uses the same origin and doesn't need to be added.
|
|
||||||
* `FIRST_SUPERUSER`: The email of the first superuser, this superuser will be the one that can create new users.
|
|
||||||
* `SMTP_HOST`: The SMTP server host to send emails, this would come from your email provider (E.g. Mailgun, Sparkpost, Sendgrid, etc).
|
|
||||||
* `SMTP_USER`: The SMTP server user to send emails.
|
|
||||||
* `SMTP_PASSWORD`: The SMTP server password to send emails.
|
|
||||||
* `EMAILS_FROM_EMAIL`: The email account to send emails from.
|
|
||||||
* `POSTGRES_USER`: The Postgres user, you can leave the default.
|
|
||||||
* `POSTGRES_DB`: The database name to use for this application. You can leave the default of `app`.
|
|
||||||
* `SENTRY_DSN`: The DSN for Sentry, if you are using it.
|
|
||||||
|
|
||||||
## GitHub Actions Environment Variables
|
|
||||||
|
|
||||||
There are some environment variables only used by GitHub Actions that you can configure:
|
|
||||||
|
|
||||||
* `LATEST_CHANGES`: Used by the GitHub Action [latest-changes](https://github.com/tiangolo/latest-changes) to automatically add release notes based on the PRs merged. It's a personal access token, read the docs for details.
|
|
||||||
* `SMOKESHOW_AUTH_KEY`: Used to handle and publish the code coverage using [Smokeshow](https://github.com/samuelcolvin/smokeshow), follow their instructions to create a (free) Smokeshow key.
|
|
||||||
|
|
||||||
### Deploy with Docker Compose
|
|
||||||
|
|
||||||
With the environment variables in place, you can deploy with Docker Compose:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /root/code/app/
|
|
||||||
docker compose -f compose.yml -f compose.deploy.yml build
|
|
||||||
docker compose -f compose.yml -f compose.deploy.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
The `compose.deploy.yml` file adds the deployment settings to the shared configuration in `compose.yml`, including HTTPS and automatic certificate handling. Explicitly listing these files also excludes the local development settings in `compose.override.yml`.
|
|
||||||
|
|
||||||
## Continuous Deployment (CD)
|
|
||||||
|
|
||||||
You can use GitHub Actions to deploy your project automatically. 😎
|
|
||||||
|
|
||||||
There are already two environment deployments configured, `staging` and `production`. Each environment should be deployed to a separate server. 🚀
|
|
||||||
|
|
||||||
### Install GitHub Actions Runner
|
|
||||||
|
|
||||||
* On your remote server, create a user for your GitHub Actions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo adduser github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Add Docker permissions to the `github` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo usermod -aG docker github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Temporarily switch to the `github` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo su - github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Go to the `github` user's home directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd
|
|
||||||
```
|
|
||||||
|
|
||||||
* [Install a GitHub Action self-hosted runner following the official guide](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/adding-self-hosted-runners#adding-a-self-hosted-runner-to-a-repository).
|
|
||||||
|
|
||||||
* When asked about labels, add a label for the environment, e.g. `production`. You can also add labels later.
|
|
||||||
|
|
||||||
After installing, the guide would tell you to run a command to start the runner. Nevertheless, it would stop once you terminate that process or if your local connection to your server is lost.
|
|
||||||
|
|
||||||
To make sure it runs on startup and continues running, you can install it as a service. To do that, exit the `github` user and go back to the `root` user:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
exit
|
|
||||||
```
|
|
||||||
|
|
||||||
After you do it, you will be on the previous user again. And you will be on the previous directory, belonging to that user.
|
|
||||||
|
|
||||||
Before being able to go the `github` user directory, you need to become the `root` user (you might already be):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo su
|
|
||||||
```
|
|
||||||
|
|
||||||
* As the `root` user, go to the `actions-runner` directory inside of the `github` user's home directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/github/actions-runner
|
|
||||||
```
|
|
||||||
|
|
||||||
* Install the self-hosted runner as a service with the user `github`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh install github
|
|
||||||
```
|
|
||||||
|
|
||||||
* Start the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh start
|
|
||||||
```
|
|
||||||
|
|
||||||
* Check the status of the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./svc.sh status
|
|
||||||
```
|
|
||||||
|
|
||||||
You can read more about it in the official guide: [Configuring the self-hosted runner application as a service](https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service).
|
|
||||||
|
|
||||||
### Configure GitHub Environments
|
|
||||||
|
|
||||||
The deployment workflows use [GitHub Environments](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments) for `staging` and `production`. This enables environment-specific secrets, deployment protection rules (e.g. required reviewers, wait timers), and deployment status tracking.
|
|
||||||
|
|
||||||
To configure them, go to your repository's **Settings** > **Environments** and create the `staging` and `production` environments.
|
|
||||||
|
|
||||||
### Set Secrets
|
|
||||||
|
|
||||||
For each GitHub Environment (`staging` and `production`), configure the required secrets as [environment secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-an-environment). Environment secrets are preferred over [repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository) because they are scoped to the specific environment, reducing exposure and aligning with any protection rules you configure.
|
|
||||||
|
|
||||||
The deployment workflows require these secrets:
|
|
||||||
|
|
||||||
* `DOMAIN`
|
|
||||||
* `FIRST_SUPERUSER`
|
* `FIRST_SUPERUSER`
|
||||||
* `FIRST_SUPERUSER_PASSWORD`
|
|
||||||
* `POSTGRES_PASSWORD`
|
Add these repository secrets:
|
||||||
|
|
||||||
|
* `DATABASE_URL`
|
||||||
* `SECRET_KEY`
|
* `SECRET_KEY`
|
||||||
|
* `FIRST_SUPERUSER_PASSWORD`
|
||||||
|
|
||||||
To enable emails, configure these additional secrets with the values from your email provider:
|
Use the same values configured in FastAPI Cloud. For `DATABASE_URL`, use the connection URL from your database provider. The database must be reachable from GitHub-hosted runners so the preparation step can connect to it.
|
||||||
|
|
||||||
* `SMTP_HOST`
|
The deployment workflow performs these steps:
|
||||||
* `SMTP_USER`
|
|
||||||
* `SMTP_PASSWORD`
|
|
||||||
* `EMAILS_FROM_EMAIL`
|
|
||||||
|
|
||||||
To enable Sentry, configure the `SENTRY_DSN` secret.
|
1. Installs and builds the frontend into `backend/app/frontend`.
|
||||||
|
2. Runs `backend/scripts/prestart.sh` to apply database migrations and create the first superuser.
|
||||||
## GitHub Action Deployment Workflows
|
3. Deploys the project with `uv run fastapi deploy`.
|
||||||
|
|
||||||
There are GitHub Action workflows in the `.github/workflows` directory already configured for deploying to the environments (GitHub Actions runners with the labels):
|
|
||||||
|
|
||||||
* `staging`: after pushing (or merging) to the branch `master`.
|
|
||||||
* `production`: after publishing a release.
|
|
||||||
|
|
||||||
Both workflows are associated with their respective GitHub Environments, so deployments will be visible in the repository's **Environments** section and will respect any protection rules you configure.
|
|
||||||
|
|
||||||
If you need to add extra environments you could use those as a starting point.
|
|
||||||
|
|
||||||
## URLs
|
## URLs
|
||||||
|
|
||||||
Replace `fastapi-project.example.com` with your domain.
|
Replace `your-app.fastapicloud.dev` with the URL of your FastAPI Cloud application.
|
||||||
|
|
||||||
### Production
|
Application (frontend and API): `https://your-app.fastapicloud.dev`
|
||||||
|
|
||||||
Application (frontend and API): `https://fastapi-project.example.com`
|
Interactive API docs: `https://your-app.fastapicloud.dev/docs`
|
||||||
|
|
||||||
Interactive API docs: `https://fastapi-project.example.com/docs`
|
## Docker Compose
|
||||||
|
|
||||||
Adminer: `https://adminer.fastapi-project.example.com`
|
For deployment to your own server, see the [Docker Compose deployment guide](./deployment-docker-compose.md).
|
||||||
|
|
||||||
### Staging
|
## GitHub Repository Automation
|
||||||
|
|
||||||
Application (frontend and API): `https://staging.fastapi-project.example.com`
|
Install the following GitHub Apps to enable the included repository automation:
|
||||||
|
|
||||||
Interactive API docs: `https://staging.fastapi-project.example.com/docs`
|
* [Latest Changes](https://github.com/apps/latest-changes) updates `release-notes.md` when a pull request is merged.
|
||||||
|
* [PR Push](https://github.com/apps/pr-push) lets the pre-commit workflow push automated fixes to pull request branches.
|
||||||
|
* [PR Submit](https://github.com/apps/pr-submit) lets the **Bump pre-commit hooks** and **Prepare Release** workflows create pull requests.
|
||||||
|
|
||||||
Adminer: `https://adminer.staging.fastapi-project.example.com`
|
To publish code coverage with [Smokeshow](https://github.com/samuelcolvin/smokeshow), add `SMOKESHOW_AUTH_KEY` as a repository secret.
|
||||||
|
|||||||
+3
-48
@@ -57,6 +57,7 @@ The build is written to `backend/app/frontend` and served by FastAPI at <http://
|
|||||||
To run the backend and built frontend in Docker Compose:
|
To run the backend and built frontend in Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
docker compose run --rm backend bash scripts/prestart.sh
|
||||||
docker compose watch
|
docker compose watch
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -74,40 +75,12 @@ Mailcatcher: <http://localhost:1080>
|
|||||||
|
|
||||||
Stop a locally running FastAPI server before starting the Compose backend because both use port `8000`.
|
Stop a locally running FastAPI server before starting the Compose backend because both use port `8000`.
|
||||||
|
|
||||||
**Note**: The first time you start the stack, it might take a minute to be ready while the backend waits for the database and configures everything. To monitor it, use `docker compose logs`, or `docker compose logs backend` for the backend service.
|
**Note**: The first time you start the stack, it might take a minute for all the services to be ready. To monitor it, use `docker compose logs`, or `docker compose logs backend` for the backend service.
|
||||||
|
|
||||||
## Mailcatcher
|
## Mailcatcher
|
||||||
|
|
||||||
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>.
|
Mailcatcher captures emails sent during local development instead of delivering them. The local backend connects to it at `localhost:1025`, and the Compose backend connects to the `mailcatcher` service. Captured emails are available at <http://localhost:1080>.
|
||||||
|
|
||||||
## Docker Compose in `localhost.tiangolo.com`
|
|
||||||
|
|
||||||
When you start the Docker Compose stack, it uses `localhost` by default, with different ports for each service (backend, adminer, etc).
|
|
||||||
|
|
||||||
When you deploy it to production (or staging), the application uses one domain. The frontend is served at `/` and the API lives under `/api`.
|
|
||||||
|
|
||||||
In the guide about [deployment](deployment.md) you can read about Traefik, the configured proxy. That's the component in charge of transmitting traffic to the application service based on the domain.
|
|
||||||
|
|
||||||
If you want to test that it's all working locally, you can edit the local `.env` file, and change:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
DOMAIN=localhost.tiangolo.com
|
|
||||||
```
|
|
||||||
|
|
||||||
That will be used by the Docker Compose files to configure the base domain for the services.
|
|
||||||
|
|
||||||
Traefik will transmit application traffic at `localhost.tiangolo.com` to FastAPI, which serves both the frontend and API.
|
|
||||||
|
|
||||||
The domain `localhost.tiangolo.com` is a special domain that is configured (with all its subdomains) to point to `127.0.0.1`. This way you can use that for your local development.
|
|
||||||
|
|
||||||
After you update it, run again:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose watch
|
|
||||||
```
|
|
||||||
|
|
||||||
Traefik is included in the main Docker Compose file. The development overrides expose its local dashboard so you can inspect the routes used with `localhost.tiangolo.com`.
|
|
||||||
|
|
||||||
## Docker Compose files and env vars
|
## Docker Compose files and env vars
|
||||||
|
|
||||||
There is a main `compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
|
There is a main `compose.yml` file with all the configurations that apply to the whole stack, it is used automatically by `docker compose`.
|
||||||
@@ -118,8 +91,6 @@ The `compose.deploy.yml` file contains the deployment-specific settings, includi
|
|||||||
|
|
||||||
The backend reads local settings from the `.env` file. Docker Compose also uses it for variable interpolation and passes the settings each container needs.
|
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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -185,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
|
||||||
|
|
||||||
@@ -202,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
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -15,9 +15,9 @@ bun run dev
|
|||||||
|
|
||||||
* Then open your browser at http://localhost:5173/.
|
* Then open your browser at http://localhost:5173/.
|
||||||
|
|
||||||
Run the backend locally with `uv run fastapi dev` and PostgreSQL in Docker Compose. See [../development.md](../development.md) for the complete setup.
|
Run `uv run bash scripts/prestart.sh` and `uv run fastapi dev` from the `backend` directory, with PostgreSQL running in Docker Compose. See [../development.md](../development.md) for the complete setup.
|
||||||
|
|
||||||
To serve the frontend with FastAPI, run `bun run build` from the `frontend` directory and open `http://localhost:8000`. To run the full stack with Docker Compose, use `docker compose watch`.
|
To serve the frontend with FastAPI, run `bun run build` from the `frontend` directory and open `http://localhost:8000`.
|
||||||
|
|
||||||
Check the file `package.json` to see other available options.
|
Check the file `package.json` to see other available options.
|
||||||
|
|
||||||
@@ -33,13 +33,16 @@ If you are developing an API-only app and want to remove the frontend, you can d
|
|||||||
|
|
||||||
* In the `compose.override.yml` file, remove the `playwright` service.
|
* In the `compose.override.yml` file, remove the `playwright` service.
|
||||||
|
|
||||||
|
* In the `.github/workflows/deploy.yml` file, remove the **Set up Bun**, **Install frontend dependencies**, and **Build frontend** steps.
|
||||||
|
|
||||||
|
* In the `.fastapicloudignore` file, remove the `!backend/app/frontend/` entry.
|
||||||
|
|
||||||
Done, you have a frontend-less (api-only) app. 🤓
|
Done, you have a frontend-less (api-only) app. 🤓
|
||||||
|
|
||||||
## 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
|
||||||
@@ -90,6 +93,7 @@ The frontend code is structured as follows:
|
|||||||
The frontend includes initial end-to-end tests using Playwright. To run the tests, you need to have the Docker Compose stack running. Start the stack with the following command:
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
|
"$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
|
||||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
"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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+44
-2
@@ -2,13 +2,52 @@
|
|||||||
|
|
||||||
## Latest Changes
|
## Latest Changes
|
||||||
|
|
||||||
|
## 0.12.0 (2026-08-12)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
* 🔧 Use FastAPI entrypoint. PR [#2360](https://github.com/fastapi/full-stack-fastapi-template/pull/2360) by [@tiangolo](https://github.com/tiangolo).
|
* ☁️ Add FastAPI Cloud deployment. PR [#2438](https://github.com/fastapi/full-stack-fastapi-template/pull/2438) by [@tiangolo](https://github.com/tiangolo).
|
||||||
* ♻️ 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
|
||||||
|
|
||||||
|
* 🐳 Include frontend assets in Docker build context. PR [#2440](https://github.com/fastapi/full-stack-fastapi-template/pull/2440) by [@tiangolo](https://github.com/tiangolo).
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
* 👷 Migrate automatic labels to Latest Changes. PR [#2439](https://github.com/fastapi/full-stack-fastapi-template/pull/2439) by [@tiangolo](https://github.com/tiangolo).
|
||||||
|
|
||||||
|
## 0.11.1 (2026-08-11)
|
||||||
|
|
||||||
### Refactors
|
### 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).
|
* ♻️ 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 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 Copier environment configuration. PR [#2416](https://github.com/fastapi/full-stack-fastapi-template/pull/2416) by [@tiangolo](https://github.com/tiangolo).
|
||||||
@@ -30,6 +69,7 @@
|
|||||||
|
|
||||||
### Docs
|
### 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).
|
* 📝 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).
|
||||||
@@ -38,6 +78,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