From 3ee7d1b760f8ddfd50e24a20f1887d52541ec726 Mon Sep 17 00:00:00 2001 From: chriscarrollsmith Date: Tue, 30 Jun 2026 20:08:29 -0400 Subject: [PATCH] docs: migrate documentation from raw Quarto to Great Docs Replace the hand-authored Quarto site with Great Docs user guide flow so narrative docs build and deploy from user_guide/ while README stays a normal checked-in file. --- .github/workflows/publish.yml | 33 +- .gitignore | 10 +- README.md | 28 +- _quarto.yml | 39 - docs/static/documentation.txt | 1549 ----------------- great-docs.yml | 30 + pyproject.toml | 2 +- index.qmd => user_guide/00-overview.qmd | 74 +- .../01-architecture.qmd | 1 + .../02-authentication.qmd | 1 + .../03-installation.qmd | 11 +- .../04-customization.qmd | 20 +- .../05-deployment.qmd | 1 + .../06-contributing.qmd | 23 +- user_guide/static/screenshot.jpg | Bin 0 -> 104394 bytes user_guide/static/styles.css | 1 + uv.lock | 72 +- 17 files changed, 162 insertions(+), 1733 deletions(-) delete mode 100644 _quarto.yml delete mode 100644 docs/static/documentation.txt create mode 100644 great-docs.yml rename index.qmd => user_guide/00-overview.qmd (63%) rename docs/architecture.qmd => user_guide/01-architecture.qmd (99%) rename docs/authentication.qmd => user_guide/02-authentication.qmd (99%) rename docs/installation.qmd => user_guide/03-installation.qmd (94%) rename docs/customization.qmd => user_guide/04-customization.qmd (96%) rename docs/deployment.qmd => user_guide/05-deployment.qmd (99%) rename docs/contributing.qmd => user_guide/06-contributing.qmd (65%) create mode 100644 user_guide/static/screenshot.jpg create mode 100644 user_guide/static/styles.css diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a7069df..6e0534d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,8 @@ jobs: permissions: id-token: write contents: write - + pages: write + services: postgres: image: postgres:latest @@ -55,11 +56,6 @@ jobs: - name: Set up Quarto uses: quarto-dev/quarto-actions/setup@v2 - - name: Configure Git - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "github-actions[bot]@users.noreply.github.com" - - name: Set required env variables run: | echo "DB_USER=postgres" > _environment @@ -75,20 +71,17 @@ jobs: - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 - - name: Render Documentation + - name: Build Documentation run: | - # Publish Quarto documentation - uv run quarto publish gh-pages --no-prompt --no-browser - - # Render README in GitHub Flavored Markdown - uv run quarto render index.qmd --output-dir . --output README.md --to gfm - - # Remove index.html if it exists - rm -f index.html + set -a + source _environment + set +a + PYTHONPATH=. uv run great-docs build - - name: Commit version change - uses: stefanzweifel/git-auto-commit-action@v5 + - name: Deploy Documentation + uses: peaceiris/actions-gh-pages@v4 with: - commit_message: "Update README" - branch: ${{ github.ref_name }} - file_pattern: "README.md" + github_token: ${{ steps.create_token.outputs.token }} + publish_dir: ./great-docs/_site + publish_branch: gh-pages + force_orphan: false diff --git a/.gitignore b/.gitignore index 5263845..21694f3 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,12 @@ package.json .specstory repomix-output.txt artifacts/ -.cursorindexingignore \ No newline at end of file +.cursorindexingignore +# Great Docs build directory (ephemeral, do not commit) +great-docs/ + +# Great Docs versioned-build artifacts +_great_docs_build/ +.great-docs-build/ +.great-docs-cache/ +.great-docs/ diff --git a/README.md b/README.md index cd7b04e..5f45587 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ ![Screenshot of the FastAPI webapp template -homepage](docs/static/screenshot.jpg) +homepage](user_guide/static/screenshot.jpg) ## Quickstart This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on -[features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/index.html), -[installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html), -[architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html), +[features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/), +[installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/installation.html), +[architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/architecture.html), [conventions, code style, and -customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/customization.html), +customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/customization.html), [deployment to cloud -platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/deployment.html), +platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/deployment.html), and -[contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/contributing.html). +[contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/contributing.html). ## Features @@ -60,8 +60,7 @@ to deploy to any major cloud hosting platform. - [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework - [Docker](https://www.docker.com/): development containerization - [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline -- [Quarto](https://quarto.org/docs/): simple documentation website - renderer +- [Great Docs](https://posit-dev.github.io/great-docs/): documentation website generator - [ty](https://docs.astral.sh/ty/): static type checker for Python - [Bootstrap](https://getbootstrap.com/): HTML/CSS styler - [Resend](https://resend.com/): zero- or low-cost email service used @@ -70,7 +69,7 @@ to deploy to any major cloud hosting platform. ## Installation For comprehensive installation instructions, see the [installation -page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). +page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/installation.html). ### uv @@ -182,10 +181,11 @@ uv run ty check . ## Developing with LLMs The `.cursor/rules` folder contains a set of AI rules for working on -this codebase in the Cursor IDE. We have also exposed the full -Markdown-formatted project documentation as a [single text -file](docs/static/documentation.txt) for easy downloading and embedding -for RAG. +this codebase in the Cursor IDE. The documentation site also publishes +[llms.txt](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/llms.txt) +and +[llms-full.txt](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/llms-full.txt) +for easy downloading and embedding for RAG. ## Contributing diff --git a/_quarto.yml b/_quarto.yml deleted file mode 100644 index 4f0830a..0000000 --- a/_quarto.yml +++ /dev/null @@ -1,39 +0,0 @@ -project: - type: website - output-dir: _docs - -website: - title: "FastAPI Webapp Template" - site-url: "https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/" - description: "A production-ready FastAPI web application template with Jinja2 templates, PostgreSQL database, and built-in authentication." - open-graph: true - twitter-card: true - navbar: - left: - - href: index.qmd - text: Home - - href: docs/architecture.qmd - text: Architecture - - href: docs/authentication.qmd - text: Authentication - - href: docs/installation.qmd - text: Installation - - href: docs/customization.qmd - text: Customization - - href: docs/deployment.qmd - text: Deployment - - href: docs/contributing.qmd - text: Contributing - right: - - icon: github - href: https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp - page-footer: - left: "Copyright 2024, Promptly Technologies, LLC. MIT License." - right: - - icon: github - href: https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp -format: - html: - theme: cosmo - css: docs/static/styles.css - toc: true diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt deleted file mode 100644 index 51ea46b..0000000 --- a/docs/static/documentation.txt +++ /dev/null @@ -1,1549 +0,0 @@ ---- -title: "FastAPI, Jinja2, PostgreSQL Webapp Template" -description: "A production-ready web application template combining FastAPI, Jinja2, and PostgreSQL with secure authentication, role-based access control, and easy cloud deployment." ---- - -![Screenshot of the FastAPI webapp template homepage](docs/static/screenshot.jpg) - -## Quickstart - -This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on [features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/index.html), [installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html), [conventions, code style, and customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/customization.html), [deployment to cloud platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/deployment.html), and [contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/contributing.html). - -## Features - -This template combines three of the most lightweight and performant open-source web development frameworks into a customizable webapp template with: - -- Pure Python backend -- Minimal-Javascript frontend -- Powerful, easy-to-manage database - -The template also includes full-featured secure auth with: - -- Token-based authentication -- Password recovery flow -- Role-based access control system - -## Design Philosophy - -The design philosophy of the template is to prefer low-level, best-in-class open-source frameworks that offer flexibility, scalability, and performance without vendor-lock-in. You'll find the template amazingly easy not only to understand and customize, but also to deploy to any major cloud hosting platform. - -## Tech Stack - -**Core frameworks:** - -- [FastAPI](https://fastapi.tiangolo.com/): scalable, high-performance, type-annotated Python web backend framework -- [PostgreSQL](https://www.postgresql.org/): the world's most advanced open-source database engine -- [Jinja2](https://jinja.palletsprojects.com/en/3.1.x/): frontend HTML templating engine -- [SQLModel](https://sqlmodel.tiangolo.com/): easy-to-use Python ORM - -**Additional technologies:** - -- [uv](https://docs.astral.sh/uv/): Python dependency manager -- [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework -- [Docker](https://www.docker.com/): development containerization -- [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline -- [Quarto](https://quarto.org/docs/): simple documentation website renderer -- [ty](https://docs.astral.sh/ty/): static type checker for Python -- [Bootstrap](https://getbootstrap.com/): HTML/CSS styler -- [Resend](https://resend.com/): zero- or low-cost email service used for password recovery - -## Installation - -For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). - -### uv - -MacOS and Linux: - -``` bash -wget -qO- https://astral.sh/uv/install.sh | sh -``` - -Windows: - -``` bash -powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" -``` - -See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information. - -### Python - -Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv: - -``` bash -# Installs the latest version -uv python install -``` - -### Docker and Docker Compose - -Install Docker Desktop and Coker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/). - -### PostgreSQL headers - -For Ubuntu/Debian: - -``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev -``` - -For macOS: - -``` bash -brew install postgresql -``` - -For Windows: - -- No installation required - -### Python dependencies - -From the root directory, run: - -``` bash -uv venv -uv sync -``` - -This will create an in-project virtual environment and install all dependencies. - -### Set environment variables - -Copy `.env.example` to `.env` with `cp .env.example .env`. - -Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. - -Set your desired database name, username, and password in the .env file. - -To use password recovery and other email features, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and the email address you want to send emails from into the .env file. Note that you will need to [verify a domain through the Resend dashboard](https://resend.com/docs/dashboard/domains/introduction) to send emails from that domain. - -### Start development database - -To start the development database, run the following command in your terminal from the root directory: - -``` bash -docker compose up -d -``` - -### Run the development server - -Make sure the development database is running and tables and default permissions/roles are created first. - -``` bash -uv run python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload -``` - -Navigate to http://localhost:8000/ - -### Type check with ty - -``` bash -uv run ty check . -``` - -## Developing with LLMs - -``` {python} -#| echo: false -#| include: false -import re -from pathlib import Path - - -def extract_file_paths(quarto_yml_path): - """ - Extract href paths from _quarto.yml file. - Returns a list of .qmd file paths. - """ - with open(quarto_yml_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Find all href entries that point to .qmd files - pattern = r'^\s*-\s*href:\s*(.*?\.qmd)\s*$' - matches = re.findall(pattern, content, re.MULTILINE) - return matches - - -def process_qmd_content(file_path): - """ - Process a .qmd file by converting YAML frontmatter to markdown heading. - Returns the processed content as a string. - """ - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Replace YAML frontmatter with markdown heading - pattern = r'^---\s*\ntitle:\s*"([^"]+)"\s*\n---' - processed_content = re.sub(pattern, r'# \1', content) - return processed_content - - -# Get the current working directory -base_dir = Path.cwd() -quarto_yml_path = base_dir / '_quarto.yml' - -# Extract file paths from _quarto.yml -qmd_files = extract_file_paths(quarto_yml_path) - -# Process each .qmd file and collect contents -processed_contents = [] -for qmd_file in qmd_files: - file_path = base_dir / qmd_file - if file_path.exists(): - processed_content = process_qmd_content(file_path) - processed_contents.append(processed_content) - -# Concatenate all contents with double newline separator -final_content = '\n\n'.join(processed_contents) - -# Ensure the output directory exists -output_dir = base_dir / 'docs' / 'static' -output_dir.mkdir(parents=True, exist_ok=True) - -# Write the concatenated content to the output file -output_path = output_dir / 'documentation.txt' -with open(output_path, 'w', encoding='utf-8') as f: - f.write(final_content) -``` - -The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG. - -## Contributing - -Your contributions are welcome! See the [issues page](https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp/issues) for ideas. Fork the repository, create a new branch, make your changes, and submit a pull request. - -## License - -This project is created and maintained by [Promptly Technologies, LLC](https://promptlytechnologies.com/) and licensed under the MIT License. See the LICENSE file for more details. - - ---- -title: "Architecture" -description: "Overview of the FastAPI webapp template architecture including data flow, database schema, project structure, and routing." ---- - -## Data flow - -This application uses a **hybrid Post-Redirect-Get (PRG) + HTMX** architecture. Every mutating endpoint supports both paths simultaneously: - -- **Non-HTMX path (PRG):** A standard browser form submission sends a POST request. On success the server issues a `303 See Other` redirect to a GET endpoint, which re-renders the full page with updated data. On error a full-page error template is returned. -- **HTMX path:** When the browser sends the `HX-Request: true` header (added automatically by [htmx.org](https://htmx.org)), the same POST endpoint detects the header via `utils/core/htmx.py:is_htmx_request()` and instead returns a `200` HTML partial that HTMX swaps into the relevant section of the page. On error a toast partial is returned and swapped into `#toast-container` via out-of-band (OOB) swap. - -The HTMX rollout keeps the existing POST route contract intact — dedicated `PUT`/`PATCH`/`DELETE` routes may be introduced in a future iteration. - -### PRG path - -``` {python} -#| echo: false -#| include: false -from graphviz import Digraph - -dot = Digraph() -dot.attr(rankdir='TB') -dot.attr('node', shape='box', style='rounded') - -# Create client subgraph at top -with dot.subgraph(name='cluster_client') as client: - client.attr(label='Client') - client.attr(rank='topmost') - client.node('A', 'User submits form', fillcolor='lightblue', style='rounded,filled') - client.node('B', 'HTML/JS form validation', fillcolor='lightblue', style='rounded,filled') - -# Create server subgraph below -with dot.subgraph(name='cluster_server') as server: - server.attr(label='Server') - server.node('C', 'FastAPI request validation in route signature', fillcolor='lightgreen', style='rounded,filled') - server.node('D', 'Business logic validation in route function body', fillcolor='lightgreen', style='rounded,filled') - server.node('E', 'Update database', fillcolor='lightgreen', style='rounded,filled') - server.node('F', 'Middleware error handler', fillcolor='lightgreen', style='rounded,filled') - server.node('G', 'Render error template', fillcolor='lightgreen', style='rounded,filled') - server.node('H', 'Redirect to GET endpoint', fillcolor='lightgreen', style='rounded,filled') - server.node('I', 'Fetch updated data', fillcolor='lightgreen', style='rounded,filled') - server.node('K', 'Re-render Jinja2 page template', fillcolor='lightgreen', style='rounded,filled') - -with dot.subgraph(name='cluster_client_post') as client_post: - client_post.attr(label='Client') - client_post.attr(rank='bottommost') - client_post.node('J', 'Display rendered page', fillcolor='lightblue', style='rounded,filled') - -# Add visible edges -dot.edge('A', 'B') -dot.edge('B', 'A') -dot.edge('B', 'C', label='POST Request to FastAPI endpoint') -dot.edge('C', 'D') -dot.edge('C', 'F', label='RequestValidationError') -dot.edge('D', 'E', label='Valid data') -dot.edge('D', 'F', label='Custom Validation Error') -dot.edge('E', 'H', label='Data updated') -dot.edge('H', 'I') -dot.edge('I', 'K') -dot.edge('K', 'J', label='Return HTML') -dot.edge('F', 'G') -dot.edge('G', 'J', label='Return HTML') - -dot.render('static/data_flow_prg', format='png', cleanup=True) -``` - -![PRG data flow diagram](static/data_flow_prg.png) - -### HTMX path - -``` {python} -#| echo: false -#| include: false -from graphviz import Digraph - -dot = Digraph() -dot.attr(rankdir='TB') -dot.attr('node', shape='box', style='rounded') - -with dot.subgraph(name='cluster_client_htmx') as client: - client.attr(label='Client (HTMX)') - client.node('A2', 'User submits form', fillcolor='lightblue', style='rounded,filled') - client.node('B2', 'HTML/JS form validation', fillcolor='lightblue', style='rounded,filled') - -with dot.subgraph(name='cluster_server_htmx') as server: - server.attr(label='Server') - server.node('C2', 'FastAPI request validation\n(HX-Request: true detected)', fillcolor='lightgreen', style='rounded,filled') - server.node('D2', 'Business logic validation', fillcolor='lightgreen', style='rounded,filled') - server.node('E2', 'Update database', fillcolor='lightgreen', style='rounded,filled') - server.node('F2', 'Exception handler\n(is_htmx_request branch)', fillcolor='lightgreen', style='rounded,filled') - server.node('G2', 'Render toast partial\n(#toast-container OOB swap)', fillcolor='lightgreen', style='rounded,filled') - server.node('H2', 'Render HTML partial\n(target element swap)', fillcolor='lightgreen', style='rounded,filled') - -with dot.subgraph(name='cluster_client_post_htmx') as client_post: - client_post.attr(label='Client (HTMX)') - client_post.node('J2', 'HTMX swaps partial\ninto DOM', fillcolor='lightblue', style='rounded,filled') - client_post.node('K2', 'Toast displayed\n(no page reload)', fillcolor='lightyellow', style='rounded,filled') - -dot.edge('A2', 'B2') -dot.edge('B2', 'A2') -dot.edge('B2', 'C2', label='POST + HX-Request: true') -dot.edge('C2', 'D2') -dot.edge('C2', 'F2', label='RequestValidationError') -dot.edge('D2', 'E2', label='Valid data') -dot.edge('D2', 'F2', label='Custom Validation Error') -dot.edge('E2', 'H2', label='Data updated') -dot.edge('H2', 'J2', label='200 HTML partial') -dot.edge('F2', 'G2') -dot.edge('G2', 'K2', label='422/400/401 toast partial') - -dot.render('static/data_flow_htmx', format='png', cleanup=True) -``` - -![HTMX data flow diagram](static/data_flow_htmx.png) - -The PRG path is preserved for all non-HTMX clients (e.g. browsers with JavaScript disabled, automated tests that do not send `HX-Request`). The HTMX path adds in-place partial updates and toast-based error handling on top of the same POST endpoints, with no change to the route URLs or form field contracts. - -### Form validation and error handling - -| Scenario | Non-HTMX (PRG) | HTMX | -|---|---|---| -| `RequestValidationError` (missing/invalid field) | Full-page error template, `422` | Toast partial via `#toast-container` OOB swap, `422` | -| Business logic error (`HTTPException`) | Full-page error template | Toast partial, `400`/`401`/`403`/`404` | -| Login failure (`CredentialsError`) | Full-page error template, `401` | Toast partial, `401` | -| Success | `303` redirect → GET → full page | `200` HTML partial swapped into target element | - -Toast partials are rendered from `templates/base/partials/toast.html` and injected into the persistent `#toast-container` div in `base.html` using `hx-swap-oob="true"`. The toast is displayed using Bootstrap's toast component and can be dismissed by the user. - -### HTMX request detection - -All HTMX-aware endpoints use the `is_htmx_request()` helper from `utils/core/htmx.py`: - -```python -def is_htmx_request(request: Request) -> bool: - return request.headers.get("HX-Request") == "true" -``` - -HTMX automatically adds the `HX-Request: true` header to every request it initiates. Non-HTMX form submissions (standard browser POSTs) do not include this header, so they follow the PRG path unchanged. - ---- -title: "Authentication" -description: "Guide to the FastAPI webapp template's authentication system including token-based auth, password recovery, and role-based access control." ---- - -## Security features - -This template implements a comprehensive authentication system with security best practices: - -1. **Token Security**: - - JWT-based with separate access/refresh tokens - - Strict expiry times (30 min access, 30 day refresh) - - Token type validation - - HTTP-only cookies - - Secure flag enabled - - SameSite=strict restriction - -2. **Password Security**: - - Strong password requirements enforced - - Bcrypt hashing with random salt - - Password reset tokens are single-use - - Reset tokens have expiration - -3. **Cookie Security**: - - HTTP-only prevents JavaScript access - - Secure flag ensures HTTPS only - - Strict SameSite prevents CSRF - -4. **Error Handling**: - - Validation errors properly handled - - Security-related errors don't leak information - - Comprehensive error logging - -The diagrams below show the main authentication flows. - -## Registration and login flow - -``` {python} -#| echo: false -#| include: false -from graphviz import Digraph - -# Create graph for registration/login -auth = Digraph(name='auth_flow') -auth.attr(rankdir='TB') -auth.attr('node', shape='box', style='rounded') - -# Client-side nodes -with auth.subgraph(name='cluster_client') as client: - client.attr(label='Client') - client.node('register_form', 'Submit registration', fillcolor='lightblue', style='rounded,filled') - client.node('login_form', 'Submit login', fillcolor='lightblue', style='rounded,filled') - client.node('store_cookies', 'Store secure cookies', fillcolor='lightblue', style='rounded,filled') - -# Server-side nodes -with auth.subgraph(name='cluster_server') as server: - server.attr(label='Server') - # Registration path - server.node('validate_register', 'Validate registration data', fillcolor='lightgreen', style='rounded,filled') - server.node('hash_new', 'Hash new password', fillcolor='lightgreen', style='rounded,filled') - server.node('store_user', 'Store user in database', fillcolor='lightgreen', style='rounded,filled') - - # Login path - server.node('validate_login', 'Validate login data', fillcolor='lightgreen', style='rounded,filled') - server.node('verify_password', 'Verify password hash', fillcolor='lightgreen', style='rounded,filled') - server.node('fetch_user', 'Fetch user from database', fillcolor='lightgreen', style='rounded,filled') - - # Common path - server.node('generate_tokens', 'Generate JWT tokens', fillcolor='lightgreen', style='rounded,filled') - -# Registration path -auth.edge('register_form', 'validate_register', 'POST /register') -auth.edge('validate_register', 'hash_new') -auth.edge('hash_new', 'store_user') -auth.edge('store_user', 'generate_tokens', 'Success') - -# Login path -auth.edge('login_form', 'validate_login', 'POST /login') -auth.edge('validate_login', 'fetch_user') -auth.edge('fetch_user', 'verify_password') -auth.edge('verify_password', 'generate_tokens', 'Success') - -# Common path -auth.edge('generate_tokens', 'store_cookies', 'Set-Cookie') - -auth.render('static/auth_flow', format='png', cleanup=True) -``` - -![Registration and login flow](static/auth_flow.png) - -## Password reset flow - -``` {python} -#| echo: false -#| include: false -from graphviz import Digraph - -# Create graph for password reset -reset = Digraph(name='reset_flow') -reset.attr(rankdir='TB') -reset.attr('node', shape='box', style='rounded') - -# Client-side nodes - using light blue fill -reset.node('forgot', 'User submits forgot password form', fillcolor='lightblue', style='rounded,filled') -reset.node('reset', 'User submits reset password form', fillcolor='lightblue', style='rounded,filled') -reset.node('email_client', 'User clicks reset link', fillcolor='lightblue', style='rounded,filled') - -# Server-side nodes - using light green fill -reset.node('validate', 'Validation', fillcolor='lightgreen', style='rounded,filled') -reset.node('token_gen', 'Generate reset token', fillcolor='lightgreen', style='rounded,filled') -reset.node('hash', 'Hash password', fillcolor='lightgreen', style='rounded,filled') -reset.node('email_server', 'Send email with Resend', fillcolor='lightgreen', style='rounded,filled') -reset.node('db', 'Database', shape='cylinder', fillcolor='lightgreen', style='filled') - -# Add edges with labels -reset.edge('forgot', 'token_gen', 'POST') -reset.edge('token_gen', 'db', 'Store') -reset.edge('token_gen', 'email_server', 'Add email/token as URL parameter') -reset.edge('email_server', 'email_client') -reset.edge('email_client', 'reset', 'Set email/token as form input') -reset.edge('reset', 'validate', 'POST') -reset.edge('validate', 'hash') -reset.edge('hash', 'db', 'Update') - -reset.render('static/reset_flow', format='png', cleanup=True) -``` - -![Password reset flow](static/reset_flow.png) - - ---- -title: "Installation" -description: "Step-by-step installation guide for the FastAPI webapp template including Dev Container setup, manual installation, and environment configuration." ---- - -## Install all development dependencies in a VSCode Dev Container - -If you use VSCode with Docker to develop in a container, the following VSCode Dev Container configuration will install all development dependencies: - -``` json -{ - "name": "Python 3", - "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-dev libpq-dev graphviz libwebp-dev && npm install bootstrap@5.3.3 && npm install -g sass && npm install -g gulp && uv venv && uv sync", - "features": { - "ghcr.io/va-h/devcontainers-features/uv:1": { - "version": "latest" - }, - "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, - "ghcr.io/rocker-org/devcontainer-features/quarto-cli:1": {} - } -} -``` - -Simply create a `.devcontainer` folder in the root of the project and add a `devcontainer.json` file in the folder with the above content. VSCode may prompt you to install the Dev Container extension if you haven't already, and/or to open the project in a container. If not, you can manually select "Dev Containers: Reopen in Container" from `View > Command Palette`. - -*IMPORTANT: If using this dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the `.env` file.* - -## Install development dependencies manually - -### uv - -MacOS and Linux: - -``` bash -wget -qO- https://astral.sh/uv/install.sh | sh -``` - -Windows: - -``` bash -powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" -``` - -See the [uv installation docs](https://docs.astral.sh/uv/getting-started/installation/) for more information. - -### Python - -Install Python 3.12 or higher from either the official [downloads page](https://www.python.org/downloads/) or using uv: - -``` bash -# Installs the latest version -uv python install -``` - -### Docker and Docker Compose - -Install Docker Desktop and Docker Compose for your operating system by following the [instructions in the documentation](https://docs.docker.com/compose/install/). - -### PostgreSQL headers - -For Ubuntu/Debian: - -``` bash -sudo apt update && sudo apt install -y python3-dev libpq-dev libwebp-dev -``` - -For macOS: - -``` bash -brew install postgresql -``` - -For Windows: - -- No installation required - -### Python dependencies - -From the root directory, run: - -``` bash -uv venv -``` - -This will create an in-project virtual environment. Then run: - -``` bash -uv sync -``` - -This will install all dependencies. - -(Note: if `psycopg2` installation fails, you probably just need to install the PostgreSQL headers first and then try again.) - -### Configure IDE - -If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`. - -It is also recommended to install the [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) and [Quarto](https://marketplace.visualstudio.com/items?itemName=quarto.quarto) IDE extensions. - -## Install documentation dependencies manually - -### Quarto CLI - -To render the project documentation, you will need to download and install the [Quarto CLI](https://quarto.org/docs/get-started/) for your operating system. - -### Graphviz - -Architecture diagrams in the documentation are rendered with [Graphviz](https://graphviz.org/). - -For macOS: - -``` bash -brew install graphviz -``` - -For Ubuntu/Debian: - -``` bash -sudo apt update && sudo apt install -y graphviz -``` - -For Windows: - -- Download and install from [Graphviz.org](https://graphviz.org/download/#windows) - -## Set environment variables - -Copy .env.example to .env with `cp .env.example .env`. - -Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into the .env file. - -Set your desired database name, username, and password in the .env file. - -To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and sender email address into the .env file. - -If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.) - -## Start development database - -To start the development database, run the following command in your terminal from the root directory: - -``` bash -docker compose up -d -``` - -If at any point you change the environment variables in the .env file, you will need to stop the database service *and tear down the volume*: - -``` bash -# Don't forget the -v flag to tear down the volume! -docker compose down -v -``` - -You may also need to restart the terminal session to pick up the new environment variables. You can also add the `--force-recreate` and `--build` flags to the startup command to ensure the container is rebuilt: - -``` bash -docker compose up -d --force-recreate --build -``` - -## Run the development server - -Before running the development server, make sure the development database is running and tables and default permissions/roles are created first. Then run the following command in your terminal from the root directory: - -``` bash -uvicorn main:app --host 0.0.0.0 --port 8000 --reload -``` - -Navigate to http://localhost:8000/. - -(Note: If startup fails with a sqlalchemy/psycopg2 connection error, make sure that Docker Desktop and the database service are running and that the environment variables in the `.env` file are correctly populated, and then try again.) - -## Type check with ty - -``` bash -uv run ty check . -``` - - ---- -title: "Customization" -description: "Guide to customizing the FastAPI webapp template including development workflow, code style conventions, and project structure." ---- - -## Development workflow - -### Dependency management with `uv` - -The project uses `uv` to manage dependencies: - -- Add new dependency: `uv add ` -- Add development dependency: `uv add --dev ` -- Remove dependency: `uv remove ` -- Update lock file: `uv lock` -- Install all dependencies: `uv sync` -- Install only production dependencies: `uv sync --no-dev` -- Upgrade dependencies: `uv lock --upgrade` - -### IDE configuration - -If you are using VSCode or Cursor as your IDE, you will need to select the `uv`-managed Python version as your interpreter for the project. Go to `View > Command Palette`, search for `Python: Select Interpreter`, and select the Python version labeled `('.venv':venv)`. - -If your IDE does not automatically detect and display this option, you can manually select the interpreter by selecting "Enter interpreter path" and then navigating to the `.venv/bin/python` subfolder in your project directory. - -### Extending the template - -The `routers/core/` and `utils/core/` directories contain the core backend logic for the template. - -Your custom Python backend code should go primarily in the `routers/app/` and `utils/app/` directories. - -For the frontend, you will also need to develop custom Jinja2 templates in the `templates/` folder and add custom static assets in `static/`. - -### Testing - -The project uses Pytest for unit testing. It's highly recommended to write and run tests before committing code to ensure nothing is broken! - -The following fixtures, defined in `tests/conftest.py`, are available in the test suite: - -- `engine`: Creates a new SQLModel engine for the test database. -- `set_up_database`: Sets up the test database before running the test suite by dropping all tables and recreating them to ensure a clean state. -- `session`: Provides a session for database operations in tests. -- `clean_db`: Cleans up the database tables before each test by deleting all entries in the `PasswordResetToken`, `EmailUpdateToken`, `User`, `Role`, `Organization`, and `Account` tables. -- `test_account`: Creates a test account with a predefined email and hashed password. -- `test_user`: Creates a test user in the database linked to the test account. -- `auth_client`: Provides a `TestClient` instance with access and refresh token cookies set, overriding the `get_session` dependency to use the `session` fixture. -- `unauth_client`: Provides a `TestClient` instance without authentication cookies set, overriding the `get_session` dependency to use the `session` fixture. -- `test_organization`: Creates a test organization for use in tests. - -To run the tests, use these commands: - -- Run all tests: `uv run pytest` -- Run tests in debug mode (includes logs and print statements in console output): `uv run pytest -s` -- Run particular test files by name: `uv run pytest ` -- Run particular tests by name: `uv run pytest -k ` - -### Type checking with ty - -The project uses type annotations and [ty](https://docs.astral.sh/ty/) for static type checking. To run ty, use this command from the root directory: - -```bash -uv run ty check . -``` - -We find that static type checking is an enormous time-saver, catching many errors early and greatly reducing time spent debugging unit tests. However, note that it requires you type annotate every variable, function, and method in your code base, so taking advantage of it requires a lifestyle change! - -### Developing with LLMs - -The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG. - -## Application architecture - -### Hybrid PRG + HTMX pattern - -In this template, we use FastAPI to define the "API endpoints" of our application. An API endpoint is simply a URL that accepts user requests and returns responses. When a user visits a page, their browser sends what's called a "GET" request to an endpoint, and the server processes it (often querying a database), and returns a response (typically HTML). The browser renders the HTML, displaying the page. - -We also create POST endpoints, which accept form submissions so the user can create, update, and delete data in the database. This template uses a **hybrid Post-Redirect-Get (PRG) + HTMX** approach for POST requests: - -- **Non-HTMX (PRG):** When a form is submitted without HTMX, the server processes the data and returns a `303 See Other` redirect to a GET endpoint, which re-renders the full page with updated data. -- **HTMX:** When HTMX submits the same form, the server detects the `HX-Request: true` header and instead returns a `200` HTML partial that HTMX swaps into the relevant part of the page — no full-page reload needed. - -Both paths use the same POST route URLs and form field contracts. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) - -#### File structure - -- FastAPI application entry point and homepage GET route: `main.py` -- Template FastAPI routes: `routers/core/` - - Account and authentication endpoints: `account.py` - - User profile management endpoints: `user.py` - - Organization management endpoints: `organization.py` - - Role management endpoints: `role.py` - - Dashboard page: `dashboard.py` - - Static pages (e.g., about, privacy policy, terms of service): `static_pages.py` -- Custom FastAPI routes for your app: `routers/app/` -- Jinja2 templates: `templates/` -- Static assets: `static/` -- Unit tests: `tests/` -- Test database configuration: `docker-compose.yml` -- Template helper functions: `utils/core/` - - Auth helpers: `auth.py` - - Database helpers: `db.py` - - FastAPI dependencies: `dependencies.py` - - Enums: `enums.py` - - Image helpers: `images.py` - - Database models: `models.py` -- Shared helper functions: `utils/` - - HTMX request detection: `htmx.py` -- Custom template helper functions for your app: `utils/app/` -- Exceptions: `exceptions/` - - HTTP exceptions: `http_exceptions.py` - - Other custom exceptions: `exceptions.py` -- Environment variables: `.env.example`, `.env` -- CI/CD configuration: `.github/` -- Project configuration: `pyproject.toml` -- Quarto documentation: - - README source: `index.qmd` - - Website source: `index.qmd` + `docs/` - - Configuration: `_quarto.yml` + `_environment` -- Rules for developing with LLMs in Cursor IDE: `.cursor/rules/` - -Most everything else is auto-generated and should not be manually modified. - -## Backend - -### Code conventions - -The GET route for the homepage is defined in the main entry point for the application, `main.py`. The entrypoint imports router modules from the `routers/core/` directory (for core/template logic) and `routers/app/` directory (for app-specific logic). In CRUD style, the core router modules are named after the resource they manage, e.g., `account.py` for account management. You should place your own endpoints in `routers/app/`. - -We name our GET routes using the convention `read_`, where `` is the name of the resource, to indicate that they are read-only endpoints that do not modify the database. In POST routes that modify the database, you can use the `get_session` dependency as an argument to get a database session. - -Routes that require authentication generally take the `get_authenticated_account` dependency as an argument. Unauthenticated GET routes generally take the `get_optional_user` dependency as an argument. If a route should *only* be seen by authenticated users (i.e., a login page), you can redirect to the dashboard if `get_optional_user` returns a `User` object. - -### Context variables - -Context refers to Python variables passed to a template to populate the HTML. In a FastAPI GET route, we can pass context to a template using the `templates.TemplateResponse` method, which takes the request and any context data as arguments. For example: - -```python -@app.get("/welcome") -async def welcome(request: Request): - return templates.TemplateResponse( - request, - "welcome.html", - {"username": "Alice"} - ) -``` - -In this example, the `welcome.html` template will receive two pieces of context: the user's `request`, which is always passed automatically by FastAPI, and a `username` variable, which we specify as "Alice". We can then use the `{{{ username }}}` syntax in the `welcome.html` template (or any of its parent or child templates) to insert the value into the HTML. - -### Email templating - -Password reset and other transactional emails are also handled through Jinja2 templates, located in the `templates/emails` directory. The email templates follow the same inheritance pattern as web templates, with `base_email.html` providing the common layout and styling. - -Here's how the default password reset email template looks: - -![Default Password Reset Email Template](static/reset_email.png) - -The email templates use inline CSS styles to ensure consistent rendering across email clients. Like web templates, they can receive context variables from the Python code (such as `reset_url` in the password reset template). - -### Server-side form validation - -Pydantic is used for data validation and serialization. It ensures that the data received in requests meets the expected format and constraints. Pydantic models are used to define the structure of request and response data, making it easy to validate and parse JSON payloads. - -If a user-submitted form contains data that has the wrong number, names, or types of fields, Pydantic will raise a `RequestValidationError`, which is caught by middleware and converted into an HTTP 422 error response. - -### Middleware exception handling - -Middlewares—which process requests before they reach the route handlers and responses before they are sent back to the client—are defined in `main.py`. They are commonly used in web development for tasks such as error handling, authentication token validation, logging, and modifying request/response objects. - -This template uses middlewares exclusively for global exception handling; they only affect requests that raise an exception. This allows for consistent error responses and centralized error logging. Middleware can catch exceptions raised during request processing and return appropriate HTTP responses. - -Middleware functions are decorated with `@app.exception_handler(ExceptionType)` and are executed in the order they are defined in `main.py`, from most to least specific. - -Here's a middleware for handling the `PasswordValidationError` exception, which returns a toast partial for HTMX requests or a full error page for non-HTMX requests: - -```python -from utils.core.htmx import is_htmx_request - -@app.exception_handler(PasswordValidationError) -async def password_validation_exception_handler(request: Request, exc: PasswordValidationError): - if is_htmx_request(request): - return templates.TemplateResponse( - request, - "base/partials/toast.html", - {"message": exc.detail, "level": "danger"}, - status_code=422, - ) - return templates.TemplateResponse( - request, - "errors/validation_error.html", - { - "status_code": 422, - "errors": {"error": exc.detail} - }, - status_code=422, - ) -``` - -## Database configuration and access with SQLModel - -SQLModel is an Object-Relational Mapping (ORM) library that allows us to interact with our PostgreSQL database using Python classes instead of writing raw SQL. It combines the features of SQLAlchemy (a powerful database toolkit) with Pydantic's data validation. - -### Models and relationships - -Core database models are defined in `utils/core/models.py`. Each model is a Python class that inherits from `SQLModel` and represents a database table. The key core models are: - -- `Account`: Represents a user account with email and password hash -- `User`: Represents a user profile with details like name and avatar; the email and password hash are stored in the related `Account` model -- `Organization`: Represents a company or team -- `Role`: Represents a set of permissions within an organization -- `Permission`: Represents specific actions a user can perform (defined by ValidPermissions enum) -- `PasswordResetToken`: Manages password reset functionality with expiration -- `EmailUpdateToken`: Manages email update confirmation functionality with expiration - -Two additional models are used by SQLModel to manage many-to-many relationships; you generally will not need to interact with them directly: - -- `UserRoleLink`: Maps users to their roles (many-to-many relationship) -- `RolePermissionLink`: Maps roles to their permissions (many-to-many relationship) - -Here's an entity-relationship diagram (ERD) of the current core database schema, automatically generated from our SQLModel definitions: - -```{python} -#| echo: false -#| warning: false -import sys -sys.path.append("..") -from utils.core.models import * -from utils.core.db import get_connection_url -from sqlmodel import create_engine -from sqlalchemy import MetaData -from sqlalchemy_schemadisplay import create_schema_graph - -# Create the directed graph -graph = create_schema_graph( - engine=create_engine(get_connection_url()), - metadata=SQLModel.metadata, - show_datatypes=True, - show_indexes=True, - rankdir='TB', - concentrate=False -) - -# Save the graph -graph.write_png('static/schema.png') -``` - -![Database Schema](static/schema.png) - -To extend the database schema, define your own models in `utils/app/models.py` and import them in `utils/core/db.py` to make sure they are included in the `metadata` object in the `create_all` function. - -### Database helpers - -Database operations are facilitated by helper functions in `utils/core/db.py` (for core logic) and `utils/app/` (for app-specific helpers). Key functions in the core utils include: - -- `set_up_db()`: Initializes the database schema and default data (which we do on every application start in `main.py`) -- `get_connection_url()`: Creates a database connection URL from environment variables in `.env` -- `get_session()`: Provides a database session for performing operations - -To perform database operations in route handlers, inject the database session as a dependency (from `utils/core/db.py`): - -```python -@app.get("/users") -async def get_users(session: Session = Depends(get_session)): - users = session.exec(select(User)).all() - return users -``` - -The session automatically handles transaction management, ensuring that database operations are atomic and consistent. - -There is also a helper method on the `User` model that checks if a user has a specific permission for a given organization. Its first argument must be a `ValidPermissions` enum value (from `utils/core/models.py`), and its second argument must be an `Organization` object or an `int` representing an organization ID: - -```python -permission = ValidPermissions.CREATE_ROLE -organization = session.exec(select(Organization).where(Organization.name == "Acme Inc.")).first() - -user.has_permission(permission, organization) -``` - -You can add custom permission enum values to the `ValidPermissions` enum in `utils/core/enums.py` (below the core permissions section) and validate that users have the necessary permissions before allowing them to modify organization data resources. - -### Cascade deletes - -Cascade deletes (in which deleting a record from one table deletes related records from another table) can be handled at either the ORM level or the database level. This template handles cascade deletes at the ORM level, via SQLModel relationships. Inside a SQLModel `Relationship`, we set: - -```python -sa_relationship_kwargs={ - "cascade": "all, delete-orphan" -} -``` - -This tells SQLAlchemy to cascade all operations (e.g., `SELECT`, `INSERT`, `UPDATE`, `DELETE`) to the related table. Since this happens through the ORM, we need to be careful to do all our database operations through the ORM using supported syntax. That generally means loading database records into Python objects and then deleting those objects rather than deleting records in the database directly. - -For example, - -```python -session.exec(delete(Role)) -``` - -will not trigger the cascade delete. Instead, we need to select the role objects and then delete them: - -```python -for role in session.exec(select(Role)).all(): - session.delete(role) -``` - -This is slower than deleting the records directly, but it makes [many-to-many relationships](https://sqlmodel.tiangolo.com/tutorial/many-to-many/create-models-with-link/#create-the-tables) much easier to manage. - -## Frontend - -### HTML templating with Jinja2 - -To generate the HTML pages to be returned from our GET routes, we use Jinja2 templates. Jinja2's hierarchical templates allow creating a base template (`templates/base.html`) that defines the overall layout of our web pages (e.g., where the header, body, and footer should go). Individual pages can then extend this base template. We can also template reusable components that can be injected into our layout or page templates. - -With Jinja2, we can use the `{% block %}` tag to define content blocks, and the `{% extends %}` tag to extend a base template. We can also use the `{% include %}` tag to include a component in a parent template. See the [Jinja2 documentation on template inheritance](https://jinja.palletsprojects.com/en/stable/templates/#template-inheritance) for more details. - -### Custom theming with Bootstrap - -[Install Node.js](https://nodejs.org/en/download/) on your local machine if it is not there already. - -Install `bootstrap`, `sass`, `gulp`, and `gulp-sass` in your project: - -```bash -npm install --save-dev bootstrap sass gulp gulp-cli gulp-sass -``` - -This will create a `node_modules` folder, a `package-lock.json` file, and a `package.json` file in the root directory of the project. - -Create an `scss` folder and a basic `scss/styles.scss` file: - -```bash -mkdir scss -touch scss/styles.scss -``` - -Your custom styles will go in `scss/styles.scss`, along with `@import` statements to include the Bootstrap components you want. - -#### Customizing the Bootstrap SCSS - -The default CSS for the template was compiled from the following `scss/styles.scss` configuration, which imports all of Bootstrap and overrides the `$theme-colors` and `$font-family-base` variables: - -```scss -// styles.scss - -// Include any default variable overrides here (functions won't be available) - -// State colors -$primary: #7464a1; -$secondary: #64a19d; -$success: #67c29c; -$info: #1cabc4; -$warning: #e4c662; -$danger: #a16468; -$light: #f8f9fa; -$dark: #343a40; - -// Bootstrap color map -$theme-colors: ( - "primary": $primary, - "secondary": $secondary, - "success": $success, - "info": $info, - "warning": $warning, - "danger": $danger, - "light": $light, - "dark": $dark -); - -$font-family-base: ( - "Nunito", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - "Helvetica Neue", - Arial, - sans-serif, - "Apple Color Emoji", - "Segoe UI Emoji", - "Segoe UI Symbol", - "Noto Color Emoji" -); - -// Include all of Bootstrap - -@import "../node_modules/bootstrap/scss/bootstrap"; -``` - -The most common use case for `styles.scss` is to define a custom color scheme and fonts, but it's also possible to customize some other visual details such as border radius and box shadow depth. See the [Bootstrap Sass customization documentation](https://getbootstrap.com/docs/5.3/customize/sass/) and the many free templates available at [Start Bootstrap](https://startbootstrap.com) for examples. - -#### Compiling the SCSS to CSS - -To compile the SCSS files to CSS, we use `gulp`. In the project root directory, create a `gulpfile.js` file with the following content: - -```javascript - const gulp = require('gulp'); - const sass = require('gulp-sass')(require('sass')); - - // Define a task to compile Sass - gulp.task('sass', function() { - return gulp.src('scss/**/*.scss') // Source folder containing Sass files - .pipe(sass().on('error', sass.logError)) - .pipe(gulp.dest('static/css')); // Destination folder for compiled CSS - }); - - // Define a default task - gulp.task('default', gulp.series('sass')); -``` - -To compile the SCSS file to `static/css`, run this command: - -```bash -npx gulp -``` - -Note that this will overwrite the existing `static/css/styles.css` file, so if you want to define any custom CSS styles, you should do so in either the `scss/styles.scss` file or in `static/css/extras.css`. - -### Client-side form validation - -While this template includes comprehensive server-side validation through Pydantic models and custom validators, it's important to note that server-side validation should be treated as a fallback security measure. For HTMX requests, validation errors are surfaced as a toast notification without a page reload. For non-HTMX requests, they render the `validation_error.html` full-page error template. In either case, seeing a validation error means client-side validation has failed to catch invalid input before it reached the server. - -Best practices dictate implementing thorough client-side validation via JavaScript and/or HTML `input` element `pattern` attributes to: - -- Provide immediate feedback to users -- Reduce server load -- Improve user experience by avoiding round-trips to the server -- Prevent malformed data from ever reaching the backend - -Server-side validation remains essential as a security measure against malicious requests that bypass client-side validation, but it should rarely be encountered during normal user interaction. See `templates/authentication/register.html` for a client-side form validation example involving both JavaScript and HTML regex `pattern` matching. - ---- -title: "Deployment" -description: "Guide to deploying the FastAPI webapp template to cloud platforms including Railway, Render, and other hosting services." ---- - -This application requires two services to be deployed and connected to each other: - -1. A PostgreSQL database (the storage layer) -2. A FastAPI app (the application layer) - -There are *many* hosting options available for each of these services; this guide will cover only a few of them. - -::: {.callout-note} -Deployment artifacts (Dockerfiles, deploy scripts, compose files, etc.) are kept on separate git branches — one per deployment target — to avoid cluttering the main branch. See the `modal` branch for Modal deployment files and the `hetzner` branch for Hetzner Cloud deployment files. The documentation below describes how to use them. -::: - -## Deploying and Configuring the FastAPI App - -### On Modal.com, with separately hosted PostgreSQL - -The big advantages of deploying on Modal.com are: -1. that they offer $30/month of free credits for each user, plus generous additional free credit allotments for startups and researchers, and -2. that it's a very user-friendly platform. - -The disadvantages are: -1. that Modal is a Python-only platform and cannot run the database layer, so you'll have to deploy that somewhere else, -2. that you'll need to make some modest changes to the codebase to get it to work on Modal, and -3. that Modal offers a [static IP address for the application server](https://modal.com/docs/guide/proxy-ips) only if you pay for a higher-tier plan starting at $250/year, which makes securing the database layer with a firewall rule cost prohibitive. - -#### Getting Started - -- [Sign up for a Modal.com account](https://modal.com/signup) -- Install modal in the project directory with `uv add modal` -- Run `uv run modal setup` to authenticate with Modal - -#### Defining the Modal Image and App - -Create a new Python file in the root of your project, for example, `deploy.py`. This file will define the Modal Image and the ASGI app deployment. - -1. **Define the Modal Image in `deploy.py`:** - - Use `modal.Image` to define the container environment. Chain methods to install dependencies and add code/files. - - Start with a Debian base image matching your Python version (e.g., 3.13). - - Install necessary system packages (`libpq-dev` for `psycopg2`, `libwebp-dev` for Pillow WebP support). - - Install Python dependencies using `run_commands` with `uv`. - - Add your local Python modules (`routers`, `utils`, `exceptions`) using `add_local_python_source`. - - Add the `static` and `templates` directories using `add_local_dir`. The default behaviour (copying on container startup) is usually fine for development, but consider `copy=True` for production stability if these files are large or rarely change. - - ```python - # deploy.py - import modal - import os - - # Define the base image - image = ( - modal.Image.debian_slim(python_version="3.13") - .apt_install("libpq-dev", "libwebp-dev") - .pip_install_from_pyproject("pyproject.toml") - .add_local_python_source("main") - .add_local_python_source("routers") - .add_local_python_source("utils") - .add_local_python_source("exceptions") - .add_local_dir("static", remote_path="/root/static") - .add_local_dir("templates", remote_path="/root/templates") - ) - - # Define the Modal App - app = modal.App( - name="your-app-name", - image=image, - secrets=[modal.Secret.from_name("your-app-name-secret")] - ) - ``` - -2. **Define the ASGI App Function in `deploy.py`:** - - Create a function decorated with `@app.function()` and `@modal.asgi_app()`. - - Inside this function, import your FastAPI application instance from `main.py`. - - Return the FastAPI app instance. - - Use `@modal.concurrent()` to allow the container to handle multiple requests concurrently. - - ```python - # deploy.py (continued) - - # Define the ASGI app function - @app.function( - allow_concurrent_inputs=100 # Adjust concurrency as needed - ) - @modal.asgi_app() - def fastapi_app(): - # Important: Import the app *inside* the function - # This ensures it runs within the Modal container environment - # and has access to the installed packages and secrets. - # It also ensures the lifespan function (db setup) runs correctly - # with the environment variables provided by the Modal Secret. - from main import app as web_app - - return web_app - ``` - -For more information on Modal FastAPI images and applications, see [this guide](https://modal.com/docs/guide/webhooks#how-do-web-endpoints-run-in-the-cloud). - -#### Deploying the App - -From your terminal, in the root directory of your project, run: - -```bash -modal deploy deploy.py -``` - -Modal will build the image (if it hasn't been built before or if dependencies changed) and deploy the ASGI app. It will output a public URL (e.g., `https://your-username--your-app-name.modal.run`). - -#### Setting Up Modal Secrets - -The application relies on environment variables stored in `.env` (like `SECRET_KEY`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `RESEND_API_KEY`, `BASE_URL`). These sensitive values should be stored securely using Modal Secrets. - -Create a Modal Secret either through the Modal UI or CLI. Note that the name of the secret has to match the secret name you used in the `deploy.py` file, above (e.g., `your-app-name-secret`). - -```bash -# Example using CLI -modal secret create your-app-name-secret \ - SECRET_KEY='your_actual_secret_key' \ - DB_USER='your_db_user' \ - DB_PASSWORD='your_db_password' \ - DB_HOST='your_external_db_host' \ - DB_PORT='your_db_port' \ - DB_NAME='your_db_name' \ - RESEND_API_KEY='your_resend_api_key' \ - BASE_URL='https://your-username--your-app-name-serve.modal.run' -``` - -**Important:** Ensure `DB_HOST` points to your *cloud* database host address, not `localhost` or `host.docker.internal`. - -#### Deploying and Configuring the PostgreSQL Database on Digital Ocean to work with Modal - -##### Getting Started - -- Create a [DigitalOcean](https://www.digitalocean.com) account -- Install the [`doctl` CLI tool](https://docs.digitalocean.com/reference/doctl) and authenticate with `doctl auth init` -- Install the [`psql` client](https://www.postgresql.org/download) - -##### Create a Project - -Create a new project to organize your resources: - -```bash -# List existing projects -doctl projects list - -# Create a new project -doctl projects create --name "YOUR-PROJECT-NAME" --purpose "YOUR-PROJECT-PURPOSE" --environment "Production" -``` - -##### Set Up a Managed PostgreSQL Database - -Create a managed, serverless PostgreSQL database instance: - -```bash -doctl databases create your-db-name --engine pg --version 17 --size db-s-1vcpu-1gb --num-nodes 1 --wait -``` - -Get the database ID from the output of the create command and use it to retrieve the database connection details: - -```bash -# Get the database connection details -doctl databases connection "your-database-id" --format Host,Port,User,Password,Database -``` - -Store these details securely in a `.env.production` file (you will need to set them later in application deployment as production secrets): - -```bash -# Database connection parameters -DB_HOST=your-host -DB_PORT=your-port -DB_USER=your-user -DB_PASS=your-password -DB_NAME=your-database -``` - -You may also want to save your database id, although you can always find it again later by listing your databases with `doctl databases list`. - -##### Setting Up a Firewall Rule (after Deploying Your Application Layer) - -Note that by default your database is publicly accessible from the Internet, so you should create a firewall rule to restrict access to only your application's IP address once you have deployed the application. The command to do this is: - -```bash -doctl databases firewalls append --rule : -``` - -where `` is `ip_addr` and `` is the IP address of the application server. See the [DigitalOcean documentation](https://docs.digitalocean.com/reference/doctl/reference/databases/firewalls/append/) for more details. - -**Note:** You can only complete this step after you have deployed your application layer and obtained a static IP address for the application server. - -#### Testing the Deployment - -Access the provided Modal URL in your browser. Browse the site and test the registration and password reset features to ensure database and Resend connections work. - -### On Hetzner Cloud, with Same-Server PostgreSQL - -Hetzner Cloud offers affordable, dedicated VMs with predictable pricing. Unlike Modal, Hetzner runs both the database and application on the same server, giving you a self-contained deployment with a static IP and automatic TLS via Caddy. - -**Advantages:** - -1. Very affordable — a 2 vCPU / 2GB server starts at ~€4/month -2. Full control over the server, static IP included -3. Runs both the app and database on a single server (no external database needed) -4. Automatic HTTPS via Caddy and Let's Encrypt - -**Disadvantages:** - -1. No automatic scaling — you get a fixed-size server -2. You are responsible for server maintenance and updates -3. You pay a fixed cost even with zero traffic - -#### Getting Started - -- Create a [Hetzner Cloud](https://www.hetzner.com/cloud) account -- Generate an API token in the Cloud Console under **Security > API Tokens** -- Install the [`hcloud` CLI](https://github.com/hetznercloud/cli) and create a context: - -```bash -hcloud context create myproject -# Paste your API token when prompted -``` - -#### Preparing the Environment File - -Copy `.env.example` to `.env.production` and fill in your production values: - -```bash -SECRET_KEY=your-secret-key -BASE_URL=https://your-domain.com -DOMAIN=your-domain.com -USE_POOL=0 -DB_HOST=db -DB_PORT=5432 -DB_USER=postgres -DB_PASSWORD=a-strong-password -DB_NAME=webapp -DB_SSLMODE=disable -RESEND_API_KEY=your-resend-key -EMAIL_FROM=noreply@your-domain.com -``` - -Note that `DB_HOST=db` refers to the Docker Compose service name — the database runs on the same server as the app. - -#### Deploying - -Run the included deploy script: - -```bash -./deploy-hetzner.sh .env.production -``` - -The script will: - -1. Create (or reuse) an SSH key and register it with Hetzner -2. Create a firewall allowing SSH, HTTP, and HTTPS -3. Provision a server with Docker pre-installed via cloud-init -4. Rsync the project files to the server -5. Start the app, database, and Caddy reverse proxy via Docker Compose - -#### DNS Setup - -After deployment, the script prints the server's IP address. Create a DNS **A record** pointing your domain to this IP. Once DNS propagates, Caddy will automatically provision a TLS certificate from Let's Encrypt. - -#### Configuration - -You can customize the server by setting environment variables before running the script: - -| Variable | Default | Description | -|---|---|---| -| `HETZNER_SERVER_NAME` | `fastapi-webapp` | Server name in Hetzner | -| `HETZNER_SERVER_TYPE` | `cpx11` | Server type (2 shared vCPU, 2GB RAM) | -| `HETZNER_IMAGE` | `ubuntu-24.04` | OS image | -| `HETZNER_LOCATION` | `ash` | Datacenter location (Ashburn, VA) | -| `HETZNER_SSH_KEY_NAME` | `deploy-key` | SSH key name in Hetzner | - -#### Managing the Deployment - -```bash -# SSH into the server -ssh root@ - -# View logs -ssh root@ 'cd /opt/app && docker compose -f docker-compose.prod.yml logs -f' - -# Restart services -ssh root@ 'cd /opt/app && docker compose -f docker-compose.prod.yml restart' - -# Redeploy after code changes (run locally) -./deploy-hetzner.sh .env.production - -# Tear down the server -hcloud server delete fastapi-webapp -``` - -## Connection Pooling - -When deploying to production with many concurrent connections or in serverless environments, you may want to use an external connection pooler like [PgBouncer](https://www.pgbouncer.org/), [Supabase Pooler](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler), or [AWS RDS Proxy](https://aws.amazon.com/rds/proxy/). - -Connection poolers sit between your application and database, managing a pool of persistent connections. This reduces connection overhead and allows your application to handle more concurrent requests without exhausting database connections. - -### Configuring Pooled Connections - -This template supports switching between direct and pooled database connections via the `USE_POOL` environment variable. - -**Direct mode (default):** Set `USE_POOL=0` or leave it unset. The application connects directly to the database using: - -- `DB_HOST` - Database host -- `DB_PORT` - Database port (typically 5432) -- `DB_NAME` - Database name -- `DB_USER` - Database username -- `DB_PASSWORD` - Database password - -**Pooled mode:** Set `USE_POOL=1`. The application connects via the pooler using: - -- `DB_HOST` - Pooler host (may be the same as the database host) -- `DB_POOL_PORT` - Pooler port (typically 6543 for PgBouncer) -- `DB_POOL_NAME` - Database name for pooled connections -- `DB_APPUSER` - Application-specific database user -- `DB_APPUSER_PASSWORD` - Application user password - -Both modes support `DB_SSLMODE` (defaults to `prefer`) for configuring SSL connections. - -### Setting Up a Restricted Application User - -When using connection pooling, it's a security best practice to create a separate database user with restricted permissions for your application's runtime connections. This follows the principle of least privilege - the app user can only perform the operations it needs, limiting potential damage from SQL injection or other vulnerabilities. - -Connect to your database as the admin user and run: - -```sql --- Create the application user -CREATE USER appuser WITH PASSWORD 'your-secure-password'; - --- Grant connect permission -GRANT CONNECT ON DATABASE yourdb TO appuser; - --- Grant usage on the public schema -GRANT USAGE ON SCHEMA public TO appuser; - --- Grant permissions on all existing tables -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO appuser; - --- Grant permissions on sequences (needed for auto-incrementing IDs) -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO appuser; - --- Ensure future tables also get these permissions -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO appuser; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO appuser; -``` - -Then configure your environment to use this user for pooled connections: - -```bash -DB_APPUSER=appuser -DB_APPUSER_PASSWORD=your-secure-password -``` - -Keep your admin credentials (`DB_USER`, `DB_PASSWORD`) for direct connections used during schema migrations or administrative tasks. - -### When to Use Connection Pooling - -Consider using a connection pooler when: - -- Running in serverless environments (Modal, AWS Lambda, Vercel) where cold starts create many new connections -- Your application handles many concurrent requests -- You're hitting database connection limits -- You want to reduce connection latency for frequently-accessed queries - ---- -title: "Contributing" -description: "How to contribute to the FastAPI webapp template project including guidelines for pull requests and development setup." ---- - -## Contributors - -### Opening issues and bug reports - -When opening a new issue or submitting a bug report, please include: - -1. A clear, descriptive title -2. For bug reports: - - Description of the expected behavior - - Description of the actual behavior - - Steps to reproduce the issue - - Version information (OS, Python version, package version) - - Any relevant error messages or screenshots -3. For feature requests: - - Description of the proposed feature - - Use case or motivation for the feature - - Any implementation suggestions (optional) - -Labels help categorize issues: -- Use `bug` for reporting problems -- Use `enhancement` for feature requests -- Use `documentation` for documentation improvements -- Use `question` for general queries - -### Contributing code - -To contribute code to the project: - -1. Fork the repository and clone your fork locally -2. Create a new branch from `main` with a descriptive name -3. Review the [customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/customization.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/architecture.html), and [authentication](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/authentication.html) pages for guidance on design patterns and code structure and style -4. Ensure all tests pass, including `ty` type checking -5. Stage, commit, and push your changes to the branch: - - Use clear, descriptive commit messages - - Keep commits focused and atomic -6. Submit your pull request: - - Provide a clear description of the changes - - Link to any related issues - -### Rendering the documentation - -The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you make changes to the `.qmd` files in the root folder and the `docs` folder, you will need to re-render the docs with Quarto. - -Quarto expects environment variables to be set in a file called `_environment`, so before running Quarto render commands, you should copy your `.env` file to `_environment`. - -``` bash -# To copy the .env file to _environment -cp .env _environment -# To render the documentation website -uv run quarto render -# To render the README -uv run quarto render index.qmd --output-dir . --output README.md --to gfm -``` - -Due to a quirk of Quarto, an unnecessary `index.html` file is created in the root folder when the README is rendered. This file can be safely deleted. - -Note that even if your pull request is merged, your changes will not be reflected on the live website until a maintainer republishes the docs. - -## Maintainers - -### Git flow - -When creating new features, - -1. Open a Github issue with the label `feature` and assign it to yourself. -2. Create a new branch from the issue sidebar. -3. Follow the instructions in the popup to check out the branch locally and make your changes on the branch. -4. Commit your changes and push to the branch. -5. When you are ready to merge, open a pull request from the branch to main. -6. Assign someone else for code review. - -### Publishing the documentation - -To publish the documentation to GitHub Pages, run the following command: - -``` bash -uv run quarto publish gh-pages -``` diff --git a/great-docs.yml b/great-docs.yml new file mode 100644 index 0000000..59ee08d --- /dev/null +++ b/great-docs.yml @@ -0,0 +1,30 @@ +# Great Docs Configuration +# See https://posit-dev.github.io/great-docs/user-guide/configuration.html + +display_name: FastAPI Webapp Template + +homepage: user_guide + +site_url: "https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/" + +pypi: false + +repo: https://github.com/promptly-technologies-llc/fastapi-jinja2-postgres-webapp + +module: utils + +parser: numpy + +dynamic: false + +authors: + - name: Christopher Carroll Smith + role: Author + email: chriscarrollsmith@gmail.com + +funding: + name: Promptly Technologies, LLC + roles: + - Copyright holder + +jupyter: python3 diff --git a/pyproject.toml b/pyproject.toml index c028b9e..9007990 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ [dependency-groups] dev = [ "graphviz<1.0.0,>=0.20.3", - "quarto<1.0.0,>=0.1.0", "jupyter<2.0.0,>=1.1.1", "notebook<8.0.0,>=7.2.2", "pytest>=8.3.3", @@ -37,6 +36,7 @@ dev = [ "pytest-playwright>=0.7.2", "pre-commit>=4.6.0", "python-semantic-release>=10.5.3", + "great-docs>=0.14.1", ] [tool.ty.rules] diff --git a/index.qmd b/user_guide/00-overview.qmd similarity index 63% rename from index.qmd rename to user_guide/00-overview.qmd index 1a9c1ee..439cad7 100644 --- a/index.qmd +++ b/user_guide/00-overview.qmd @@ -1,13 +1,14 @@ --- title: "FastAPI, Jinja2, PostgreSQL Webapp Template" description: "A production-ready web application template combining FastAPI, Jinja2, and PostgreSQL with secure authentication, role-based access control, and easy cloud deployment." +guide-section: "Getting Started" --- -![Screenshot of the FastAPI webapp template homepage](docs/static/screenshot.jpg) +![Screenshot of the FastAPI webapp template homepage](user-guide/static/screenshot.jpg) ## Quickstart -This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on [features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/index.html), [installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html), [conventions, code style, and customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/customization.html), [deployment to cloud platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/deployment.html), and [contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/contributing.html). +This quickstart guide provides a high-level overview. See the full documentation for comprehensive information on [features](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/), [installation](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/installation.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/architecture.html), [conventions, code style, and customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/customization.html), [deployment to cloud platforms](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/deployment.html), and [contributing](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/contributing.html). ## Features @@ -42,14 +43,14 @@ The design philosophy of the template is to prefer low-level, best-in-class open - [Pytest](https://docs.pytest.org/en/7.4.x/): testing framework - [Docker](https://www.docker.com/): development containerization - [Github Actions](https://docs.github.com/en/actions): CI/CD pipeline -- [Quarto](https://quarto.org/docs/): simple documentation website renderer +- [Great Docs](https://posit-dev.github.io/great-docs/): documentation website generator - [ty](https://docs.astral.sh/ty/): static type checker for Python - [Bootstrap](https://getbootstrap.com/): HTML/CSS styler - [Resend](https://resend.com/): zero- or low-cost email service used for password recovery ## Installation -For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/installation.html). +For comprehensive installation instructions, see the [installation page](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/installation.html). ### uv @@ -145,70 +146,7 @@ uv run ty check . ## Developing with LLMs -``` {python} -#| echo: false -#| include: false -import re -from pathlib import Path - - -def extract_file_paths(quarto_yml_path): - """ - Extract href paths from _quarto.yml file. - Returns a list of .qmd file paths. - """ - with open(quarto_yml_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Find all href entries that point to .qmd files - pattern = r'^\s*-\s*href:\s*(.*?\.qmd)\s*$' - matches = re.findall(pattern, content, re.MULTILINE) - return matches - - -def process_qmd_content(file_path): - """ - Process a .qmd file by converting YAML frontmatter to markdown heading. - Returns the processed content as a string. - """ - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # Replace YAML frontmatter with markdown heading - pattern = r'^---\s*\ntitle:\s*"([^"]+)"\s*\n---' - processed_content = re.sub(pattern, r'# \1', content) - return processed_content - - -# Get the current working directory -base_dir = Path.cwd() -quarto_yml_path = base_dir / '_quarto.yml' - -# Extract file paths from _quarto.yml -qmd_files = extract_file_paths(quarto_yml_path) - -# Process each .qmd file and collect contents -processed_contents = [] -for qmd_file in qmd_files: - file_path = base_dir / qmd_file - if file_path.exists(): - processed_content = process_qmd_content(file_path) - processed_contents.append(processed_content) - -# Concatenate all contents with double newline separator -final_content = '\n\n'.join(processed_contents) - -# Ensure the output directory exists -output_dir = base_dir / 'docs' / 'static' -output_dir.mkdir(parents=True, exist_ok=True) - -# Write the concatenated content to the output file -output_path = output_dir / 'documentation.txt' -with open(output_path, 'w', encoding='utf-8') as f: - f.write(final_content) -``` - -The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG. +The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. The documentation site also publishes [llms.txt](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/llms.txt) and [llms-full.txt](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/llms-full.txt) for easy downloading and embedding for RAG. ## Contributing diff --git a/docs/architecture.qmd b/user_guide/01-architecture.qmd similarity index 99% rename from docs/architecture.qmd rename to user_guide/01-architecture.qmd index 9548715..59d55ca 100644 --- a/docs/architecture.qmd +++ b/user_guide/01-architecture.qmd @@ -1,6 +1,7 @@ --- title: "Architecture" description: "Overview of the FastAPI webapp template architecture including data flow, database schema, project structure, and routing." +guide-section: "Application" --- ## Data flow diff --git a/docs/authentication.qmd b/user_guide/02-authentication.qmd similarity index 99% rename from docs/authentication.qmd rename to user_guide/02-authentication.qmd index b149f8a..b894ec4 100644 --- a/docs/authentication.qmd +++ b/user_guide/02-authentication.qmd @@ -1,6 +1,7 @@ --- title: "Authentication" description: "Guide to the FastAPI webapp template's authentication system including token-based auth, password recovery, and role-based access control." +guide-section: "Application" --- ## Security features diff --git a/docs/installation.qmd b/user_guide/03-installation.qmd similarity index 94% rename from docs/installation.qmd rename to user_guide/03-installation.qmd index fbe1d87..11e48c2 100644 --- a/docs/installation.qmd +++ b/user_guide/03-installation.qmd @@ -1,6 +1,7 @@ --- title: "Installation" description: "Step-by-step installation guide for the FastAPI webapp template including Dev Container setup, manual installation, and environment configuration." +guide-section: "Getting Started" --- ## Install all development dependencies in a VSCode Dev Container @@ -101,9 +102,15 @@ It is also recommended to install the [Python](https://marketplace.visualstudio. ## Install documentation dependencies manually -### Quarto CLI +### Great Docs -To render the project documentation, you will need to download and install the [Quarto CLI](https://quarto.org/docs/get-started/) for your operating system. +To build the project documentation, install Great Docs as a development dependency: + +``` bash +uv sync +``` + +Great Docs uses the [Quarto CLI](https://quarto.org/docs/get-started/) under the hood. Install Quarto for your operating system if it is not already available. ### Graphviz diff --git a/docs/customization.qmd b/user_guide/04-customization.qmd similarity index 96% rename from docs/customization.qmd rename to user_guide/04-customization.qmd index 5779c97..3971a70 100644 --- a/docs/customization.qmd +++ b/user_guide/04-customization.qmd @@ -1,6 +1,7 @@ --- title: "Customization" description: "Guide to customizing the FastAPI webapp template including development workflow, code style conventions, and project structure." +guide-section: "Application" --- ## Development workflow @@ -66,7 +67,7 @@ We find that static type checking is an enormous time-saver, catching many error ### Developing with LLMs -The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. We have also exposed the full Markdown-formatted project documentation as a [single text file](docs/static/documentation.txt) for easy downloading and embedding for RAG. +The `.cursor/rules` folder contains a set of AI rules for working on this codebase in the Cursor IDE. The documentation site also publishes [llms.txt](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/llms.txt) and [llms-full.txt](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/llms-full.txt) for easy downloading and embedding for RAG. ## Application architecture @@ -79,7 +80,7 @@ We also create POST endpoints, which accept form submissions so the user can cre - **Non-HTMX (PRG):** When a form is submitted without HTMX, the server processes the data and returns a `303 See Other` redirect to a GET endpoint, which re-renders the full page with updated data. - **HTMX:** When HTMX submits the same form, the server detects the `HX-Request: true` header and instead returns a `200` HTML partial that HTMX swaps into the relevant part of the page — no full-page reload needed. -Both paths use the same POST route URLs and form field contracts. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/docs/architecture.html) for more details.) +Both paths use the same POST route URLs and form field contracts. (See [Architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/architecture.html) for more details.) #### File structure @@ -112,10 +113,10 @@ Both paths use the same POST route URLs and form field contracts. (See [Architec - Environment variables: `.env.example`, `.env` - CI/CD configuration: `.github/` - Project configuration: `pyproject.toml` -- Quarto documentation: - - README source: `index.qmd` - - Website source: `index.qmd` + `docs/` - - Configuration: `_quarto.yml` + `_environment` +- Documentation: + - README: `README.md` + - Website source: `user_guide/` + - Configuration: `great-docs.yml` + `_environment` - Rules for developing with LLMs in Cursor IDE: `.cursor/rules/` Most everything else is auto-generated and should not be manually modified. @@ -222,7 +223,12 @@ Here's an entity-relationship diagram (ERD) of the current core database schema, #| echo: false #| warning: false import sys -sys.path.append("..") +from pathlib import Path + +_project_root = Path.cwd() +while _project_root != _project_root.parent and not (_project_root / "pyproject.toml").exists(): + _project_root = _project_root.parent +sys.path.insert(0, str(_project_root)) from utils.core.models import * from utils.core.db import get_connection_url from sqlmodel import create_engine diff --git a/docs/deployment.qmd b/user_guide/05-deployment.qmd similarity index 99% rename from docs/deployment.qmd rename to user_guide/05-deployment.qmd index 99e4ffa..ac94228 100644 --- a/docs/deployment.qmd +++ b/user_guide/05-deployment.qmd @@ -1,6 +1,7 @@ --- title: "Deployment" description: "Guide to deploying the FastAPI webapp template to cloud platforms including Railway, Render, and other hosting services." +guide-section: "Deployment" --- This application requires two services to be deployed and connected to each other: diff --git a/docs/contributing.qmd b/user_guide/06-contributing.qmd similarity index 65% rename from docs/contributing.qmd rename to user_guide/06-contributing.qmd index cd286a1..cd34185 100644 --- a/docs/contributing.qmd +++ b/user_guide/06-contributing.qmd @@ -1,6 +1,7 @@ --- title: "Contributing" description: "How to contribute to the FastAPI webapp template project including guidelines for pull requests and development setup." +guide-section: "Contributing" --- ## Contributors @@ -33,7 +34,7 @@ To contribute code to the project: 1. Fork the repository and clone your fork locally 2. Create a new branch from `main` with a descriptive name -3. Review the [customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/customization.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/architecture.html), and [authentication](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/authentication.html) pages for guidance on design patterns and code structure and style +3. Review the [customization](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/customization.html), [architecture](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/architecture.html), and [authentication](https://promptlytechnologies.com/fastapi-jinja2-postgres-webapp/user-guide/authentication.html) pages for guidance on design patterns and code structure and style 4. Ensure all tests pass, including `ty` type checking 5. Stage, commit, and push your changes to the branch: - Use clear, descriptive commit messages @@ -44,21 +45,19 @@ To contribute code to the project: ### Rendering the documentation -The README and documentation website are rendered with [Quarto](https://quarto.org/docs/). If you make changes to the `.qmd` files in the root folder and the `docs` folder, you will need to re-render the docs with Quarto. +The documentation website is built with [Great Docs](https://posit-dev.github.io/great-docs/). If you make changes to the `.qmd` files in the `user_guide/` folder, you will need to rebuild the docs with Great Docs. -Quarto expects environment variables to be set in a file called `_environment`, so before running Quarto render commands, you should copy your `.env` file to `_environment`. +Great Docs uses Quarto under the hood and expects environment variables to be set in a file called `_environment`, so before running Great Docs build commands, you should copy your `.env` file to `_environment`. ``` bash # To copy the .env file to _environment cp .env _environment -# To render the documentation website -uv run quarto render -# To render the README -uv run quarto render index.qmd --output-dir . --output README.md --to gfm +# To build the documentation website +PYTHONPATH=. uv run great-docs build +# To preview the documentation website locally +uv run great-docs preview ``` -Due to a quirk of Quarto, an unnecessary `index.html` file is created in the root folder when the README is rendered. This file can be safely deleted. - Note that even if your pull request is merged, your changes will not be reflected on the live website until a maintainer republishes the docs. ## Maintainers @@ -76,8 +75,4 @@ When creating new features, ### Publishing the documentation -To publish the documentation to GitHub Pages, run the following command: - -``` bash -uv run quarto publish gh-pages -``` +Documentation is published to GitHub Pages automatically when changes are merged to `main`. Maintainers can also build locally and deploy the contents of `great-docs/_site/`. diff --git a/user_guide/static/screenshot.jpg b/user_guide/static/screenshot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..c0d614bd1ef6ab7b4b4eefc8caf4ece820e2a580 GIT binary patch literal 104394 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�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
3ArftIVURD8KNDpb+`{lDg}< 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%0AuOw(T{ZS48*5 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 Dy3V&o literal 0 HcmV?d00001 diff --git a/user_guide/static/styles.css b/user_guide/static/styles.css new file mode 100644 index 0000000..2ddf50c --- /dev/null +++ b/user_guide/static/styles.css @@ -0,0 +1 @@ +/* css styles */ diff --git a/uv.lock b/uv.lock index 0541668..496be46 100644 --- a/uv.lock +++ b/uv.lock @@ -513,6 +513,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "graphviz" }, + { name = "great-docs" }, { name = "jupyter" }, { name = "notebook" }, { name = "pre-commit" }, @@ -520,7 +521,6 @@ dev = [ { name = "pytest-jinja-check", extra = ["fastapi"] }, { name = "pytest-playwright" }, { name = "python-semantic-release" }, - { name = "quarto" }, { name = "ruff" }, { name = "sqlalchemy-schemadisplay" }, { name = "ty" }, @@ -545,6 +545,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "graphviz", specifier = ">=0.20.3,<1.0.0" }, + { name = "great-docs", specifier = ">=0.14.1" }, { name = "jupyter", specifier = ">=1.1.1,<2.0.0" }, { name = "notebook", specifier = ">=7.2.2,<8.0.0" }, { name = "pre-commit", specifier = ">=4.6.0" }, @@ -552,7 +553,6 @@ dev = [ { name = "pytest-jinja-check", extras = ["fastapi"], specifier = ">=1.0.3" }, { name = "pytest-playwright", specifier = ">=0.7.2" }, { name = "python-semantic-release", specifier = ">=10.5.3" }, - { name = "quarto", specifier = ">=0.1.0,<1.0.0" }, { name = "ruff", specifier = ">=0.15.5" }, { name = "sqlalchemy-schemadisplay", specifier = ">=2.0,<3.0" }, { name = "ty", specifier = ">=0.0.21" }, @@ -618,6 +618,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, ] +[[package]] +name = "great-docs" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "griffe" }, + { name = "jupyter" }, + { name = "pillow" }, + { name = "py-yaml12" }, + { name = "pygments" }, + { name = "requests" }, + { name = "ruff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/1f/8e56546184763f3bca4d45a1d3455201b1a41c6cdbc6656e04078fa3958a/great_docs-0.14.1.tar.gz", hash = "sha256:891f0461b13143f35515202d06df8abd2a22b2e25a19f04728868bb8f182069e", size = 30994450, upload-time = "2026-06-25T20:59:53.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/68/ea67105323646c2c595172a6ec89267fc22a9d5a957ff7ec3f117eb8b6d6/great_docs-0.14.1-py3-none-any.whl", hash = "sha256:b91b569fa3173819b34b2c0fdff4277686ae3ddbe235c8d840445095d0e5fddb", size = 961301, upload-time = "2026-06-25T20:59:50.784Z" }, +] + [[package]] name = "greenlet" version = "3.5.2" @@ -675,6 +694,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/45/f794a81c91e9942c61f9110bd1f9a38a0ea565eab57f8b08cd53d3131e48/greenlet-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:db548d5ab6c2a8ead82c013f875090d79b5d7d2b67fc513934ce6cf66492ad7f", size = 242062, upload-time = "2026-06-17T17:35:39.814Z" }, ] +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1580,6 +1611,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "py-yaml12" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/eb/a0b260f7b8625d21c7a900311c863e899d2e258ebdc9a056a0cd17031f80/py_yaml12-0.1.0.tar.gz", hash = "sha256:e7b6140fad56d5528e6d51c7b8ff79bdb27631b73a9a7c1bc4b55fda1bddbaef", size = 117187, upload-time = "2025-12-16T18:06:44.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/d9/5f4ccd96d7c776e822e63d2b79e3594653bcf55720dbad1cdcbc18a170ad/py_yaml12-0.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f6dcce183b6744c0909a8440d1f7a00836023826e463601d03b879c0e84c8a19", size = 468720, upload-time = "2025-12-16T18:06:35.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ab/c08a62f92f69d45fc479b1e893968c9d9b2dc87809e538cea0409cdca25e/py_yaml12-0.1.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b9c8e54afc415d4eb038a024bc9e28fed95b8c447708bf1a24da0291bbfdd8", size = 501529, upload-time = "2025-12-16T18:06:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/b6/05/a8408ca1f4de61f8ef626391908006fa1c5e54eaf0f3288d78bc663d65aa/py_yaml12-0.1.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e637189cb07e180bdc9db847ca5711b54df2233b55604bf3609cfa0f0228f7c", size = 519733, upload-time = "2025-12-16T18:06:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/2b/73/4e1f7cdc26b1deb60ea42ca4b3717fab279a42df10f9b16b869550e92825/py_yaml12-0.1.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:003a9526a4731010f4e37eced9f33fd070ff563fe3bc2fe2416ab7598982b285", size = 640522, upload-time = "2025-12-16T18:06:25.551Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a9/7be4ced3e490fd2a5e92cc8e8fe5b6143036a480ba004235e0a78ccbc6f9/py_yaml12-0.1.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6845871e3a0233d71f75e56caca5c350f48cb5d9c50e6bb37874243be1ca5f2b", size = 530277, upload-time = "2025-12-16T18:06:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0c/1e1b171246a44753bf04c6399179da1ccf99cfb9de1e086afd7326765b34/py_yaml12-0.1.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbc5d54ec0471c0f64b931ea0907f655c728597b98a9e7f4a98f4c76cc3614ba", size = 516865, upload-time = "2025-12-16T18:06:31.845Z" }, + { url = "https://files.pythonhosted.org/packages/e3/00/b4d1db376b35f0021c7e45b6351536c217426b32f71ad8b89782a3a6b562/py_yaml12-0.1.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3d9f1ad2e166eb113fd4e44263baad929e268dc15abc7eb87736aa7148d07534", size = 557448, upload-time = "2025-12-16T18:06:28.78Z" }, + { url = "https://files.pythonhosted.org/packages/7a/41/828743df93b97ca987a808895f5171f2430ee61f18f25fe3d9033075879d/py_yaml12-0.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4c9fabfe8fdf53eb661eaaace95961dae742abe0679a9a989936e8b2cf893dbe", size = 683748, upload-time = "2025-12-16T18:06:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/74/b4/f293b9fa7ad26b86278840f3c571a211111091fd03ba553fa45e4bcaf780/py_yaml12-0.1.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0226a5aeba67b97ed8e2947da332e5c3b5ef004c0058d2cff2413c99fc4b9ff4", size = 789703, upload-time = "2025-12-16T18:06:39.272Z" }, + { url = "https://files.pythonhosted.org/packages/a9/18/703812d770166e292e13388bf899f2540c83a8c9d6505862fe756b2818d1/py_yaml12-0.1.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:a29558b3ba9138eac75996dd0a92fda7993c085aa10c1c93b641239389b731be", size = 764707, upload-time = "2025-12-16T18:06:40.943Z" }, + { url = "https://files.pythonhosted.org/packages/81/f5/936eb310b0c84d044d92ccd90b99b53fb8326dc6084ce59fe47a6b78eb93/py_yaml12-0.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fcd21f854faf23bd1fff4822e7cf29a6e8b69dc43f69f6f2f34a698dcde4548b", size = 719443, upload-time = "2025-12-16T18:06:42.464Z" }, + { url = "https://files.pythonhosted.org/packages/37/5b/eaa16c85074b44b1b6843d52fb3b75da26410dcea1e98cf6b6c4f46f27af/py_yaml12-0.1.0-cp310-abi3-win32.whl", hash = "sha256:2552c7957819f53b7d667baab38e59db066f067aafa6695849ae94c0f01bf999", size = 349644, upload-time = "2025-12-16T18:06:48.417Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/d50a1e5e44333aa6533080df9c587b6b25e0205071078c63619202592bb3/py_yaml12-0.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:228ce36885a16dcaf20007bf7b74ae1b36ccd20a46149f369e4b8164e7542023", size = 362326, upload-time = "2025-12-16T18:06:45.271Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1974,22 +2026,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, ] -[[package]] -name = "quarto" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ipykernel" }, - { name = "jupyter-core" }, - { name = "nbclient" }, - { name = "nbformat" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/a4/33dc28f6fcd5ff80ee80b2afddea9bc46a42f5e2bbc000eeb1b5f6194918/quarto-0.1.0.tar.gz", hash = "sha256:d9a4978110204f5b9d3af39faa9e195083efcbac8f6a23789e29cb27c72ded15", size = 2920, upload-time = "2021-02-03T21:53:12.381Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/14/8af79508df038ab05953ecf862420c7f4cc131b1682f0c80cb4f78c58593/quarto-0.1.0-py3-none-any.whl", hash = "sha256:8138fc9d1bee6269a5436837baedc699a262be1478f96444d6f99029f1a114f0", size = 10319, upload-time = "2021-02-03T21:53:10.812Z" }, -] - [[package]] name = "referencing" version = "0.37.0"