Skip to content

Commit 7e36a5d

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents 7d8b8a0 + a397335 commit 7e36a5d

19 files changed

Lines changed: 300 additions & 235 deletions

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
"Explore",
1111
"Find",
1212
"Search",
13-
"WebSearch"
13+
"WebSearch",
14+
"WebFetch(*)"
1415
]
1516
}
1617
}

.github/workflows/propagate.yml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: propagate
2+
3+
on:
4+
push:
5+
branches: [main]
6+
7+
permissions:
8+
contents: write
9+
pull-requests: write
10+
11+
jobs:
12+
propagate:
13+
strategy:
14+
matrix:
15+
branch: [modal, hetzner]
16+
runs-on: ubuntu-latest
17+
steps:
18+
- name: Generate token
19+
id: create_token
20+
uses: tibdex/github-app-token@v2
21+
with:
22+
app_id: ${{ secrets.APP_ID }}
23+
private_key: ${{ secrets.PRIVATE_KEY }}
24+
25+
- name: Checkout repository
26+
uses: actions/checkout@v4
27+
with:
28+
fetch-depth: 0
29+
token: ${{ steps.create_token.outputs.token }}
30+
31+
- name: Attempt merge and push, or open PR on conflict
32+
env:
33+
GH_TOKEN: ${{ steps.create_token.outputs.token }}
34+
BRANCH: ${{ matrix.branch }}
35+
run: |
36+
git config user.name "github-actions[bot]"
37+
git config user.email "github-actions[bot]@users.noreply.github.com"
38+
39+
git checkout "$BRANCH"
40+
41+
if git merge origin/main --no-edit; then
42+
echo "Merge succeeded, pushing to $BRANCH"
43+
git push origin "$BRANCH"
44+
else
45+
echo "Merge conflict detected, opening PR"
46+
git merge --abort
47+
48+
# Create a temporary branch for the PR
49+
TEMP_BRANCH="auto-merge/main-to-${BRANCH}-$(date +%Y%m%d%H%M%S)"
50+
git checkout -b "$TEMP_BRANCH" origin/main
51+
52+
git push origin "$TEMP_BRANCH"
53+
54+
gh pr create \
55+
--base "$BRANCH" \
56+
--head "$TEMP_BRANCH" \
57+
--title "Propagate main to ${BRANCH}" \
58+
--body "$(cat <<'PREOF'
59+
## Summary
60+
- Auto-generated PR to propagate changes from `main` to the deployment branch
61+
- Opened because an automatic merge had conflicts that need manual resolution
62+
63+
🤖 Generated by the propagate workflow
64+
PREOF
65+
)"
66+
fi

docs/architecture.qmd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ description: "Overview of the FastAPI webapp template architecture including dat
88
This application uses a **hybrid Post-Redirect-Get (PRG) + HTMX** architecture. Every mutating endpoint supports both paths simultaneously:
99

1010
- **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.
11-
- **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/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.
11+
- **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.
1212

1313
The HTMX rollout keeps the existing POST route contract intact — dedicated `PUT`/`PATCH`/`DELETE` routes may be introduced in a future iteration.
1414

@@ -129,7 +129,7 @@ Toast partials are rendered from `templates/base/partials/toast.html` and inject
129129

130130
### HTMX request detection
131131

132-
All HTMX-aware endpoints use the `is_htmx_request()` helper from `utils/htmx.py`:
132+
All HTMX-aware endpoints use the `is_htmx_request()` helper from `utils/core/htmx.py`:
133133

134134
```python
135135
def is_htmx_request(request: Request) -> bool:

docs/customization.qmd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ The GET route for the homepage is defined in the main entry point for the applic
128128

129129
We name our GET routes using the convention `read_<name>`, where `<name>` 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.
130130

131-
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.
131+
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. Routes that should *only* be seen by unauthenticated users (e.g., login, register) use the `require_unauthenticated_client` dependency, which automatically redirects authenticated users to the dashboard. Routes that require re-verification of credentials (e.g., account deletion) use the `get_verified_account` dependency, which wraps `get_authenticated_account` with an additional email/password check.
132132

133133
### Context variables
134134

@@ -173,7 +173,7 @@ Middleware functions are decorated with `@app.exception_handler(ExceptionType)`
173173
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:
174174

175175
```python
176-
from utils.htmx import is_htmx_request
176+
from utils.core.htmx import is_htmx_request
177177

178178
@app.exception_handler(PasswordValidationError)
179179
async def password_validation_exception_handler(request: Request, exc: PasswordValidationError):

docs/static/documentation.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ description: "Overview of the FastAPI webapp template architecture including dat
229229
This application uses a **hybrid Post-Redirect-Get (PRG) + HTMX** architecture. Every mutating endpoint supports both paths simultaneously:
230230

231231
- **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.
232-
- **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/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.
232+
- **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.
233233

234234
The HTMX rollout keeps the existing POST route contract intact — dedicated `PUT`/`PATCH`/`DELETE` routes may be introduced in a future iteration.
235235

@@ -350,7 +350,7 @@ Toast partials are rendered from `templates/base/partials/toast.html` and inject
350350

351351
### HTMX request detection
352352

353-
All HTMX-aware endpoints use the `is_htmx_request()` helper from `utils/htmx.py`:
353+
All HTMX-aware endpoints use the `is_htmx_request()` helper from `utils/core/htmx.py`:
354354

355355
```python
356356
def is_htmx_request(request: Request) -> bool:
@@ -843,7 +843,7 @@ Middleware functions are decorated with `@app.exception_handler(ExceptionType)`
843843
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:
844844

845845
```python
846-
from utils.htmx import is_htmx_request
846+
from utils.core.htmx import is_htmx_request
847847

848848
@app.exception_handler(PasswordValidationError)
849849
async def password_validation_exception_handler(request: Request, exc: PasswordValidationError):

exceptions/http_exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ def __init__(self):
3535
)
3636

3737

38+
class AlreadyAuthenticatedError(HTTPException):
39+
"""Raised when an authenticated user tries to access a page meant for unauthenticated users."""
40+
def __init__(self):
41+
super().__init__(
42+
status_code=status.HTTP_303_SEE_OTHER,
43+
headers={"Location": "/dashboard/"}
44+
)
45+
46+
3847
class PasswordValidationError(HTTPException):
3948
def __init__(self, field: str, message: str):
4049
super().__init__(

main.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
from typing import Optional
32
from contextlib import asynccontextmanager
43
from dotenv import load_dotenv
54
from fastapi import FastAPI, Request, Depends, status
@@ -10,12 +9,13 @@
109
from starlette.exceptions import HTTPException as StarletteHTTPException
1110
from routers.core import account, dashboard, organization, role, user, static_pages, invitation
1211
from utils.core.dependencies import (
13-
get_optional_user,
14-
get_user_from_request
12+
get_user_from_request,
13+
require_unauthenticated_client
1514
)
1615
from utils.core.auth import COOKIE_SECURE
17-
from utils.htmx import is_htmx_request, toast_response
16+
from utils.core.htmx import is_htmx_request, toast_response
1817
from exceptions.http_exceptions import (
18+
AlreadyAuthenticatedError,
1919
AuthenticationError,
2020
PasswordValidationError,
2121
CredentialsError,
@@ -25,7 +25,6 @@
2525
NeedsNewTokens
2626
)
2727
from utils.core.db import set_up_db
28-
from utils.core.models import User
2928

3029
logger = logging.getLogger("uvicorn.error")
3130
logger.setLevel(logging.DEBUG)
@@ -76,6 +75,19 @@ async def authentication_error_handler(request: Request, exc: AuthenticationErro
7675
)
7776

7877

78+
# Handle AlreadyAuthenticatedError by redirecting to dashboard
79+
@app.exception_handler(AlreadyAuthenticatedError)
80+
async def already_authenticated_error_handler(request: Request, exc: AlreadyAuthenticatedError):
81+
if is_htmx_request(request):
82+
response = Response(status_code=200)
83+
response.headers["HX-Redirect"] = str(request.url_for("read_dashboard"))
84+
return response
85+
return RedirectResponse(
86+
url=app.url_path_for("read_dashboard"),
87+
status_code=status.HTTP_302_FOUND
88+
)
89+
90+
7991
# Handle RateLimitError (429 Too Many Requests)
8092
@app.exception_handler(RateLimitError)
8193
async def rate_limit_error_handler(request: Request, exc: RateLimitError):
@@ -88,7 +100,7 @@ async def rate_limit_error_handler(request: Request, exc: RateLimitError):
88100
response = templates.TemplateResponse(
89101
request,
90102
"errors/error.html",
91-
{"status_code": 429, "detail": exc.detail, "user": user},
103+
{"status_code": 429, "detail": exc.detail, "errors": None, "user": user},
92104
status_code=429,
93105
)
94106
response.headers["Retry-After"] = str(exc.retry_after)
@@ -107,7 +119,7 @@ async def credentials_exception_handler(request: Request, exc: CredentialsError)
107119
return templates.TemplateResponse(
108120
request,
109121
"errors/error.html",
110-
{"status_code": exc.status_code, "detail": exc.detail, "user": user},
122+
{"status_code": exc.status_code, "detail": exc.detail, "errors": None, "user": user},
111123
status_code=exc.status_code,
112124
)
113125

@@ -162,6 +174,7 @@ async def password_validation_exception_handler(
162174
"errors/error.html",
163175
{
164176
"status_code": 422,
177+
"detail": None,
165178
"errors": {field.replace("_", " ").title(): message},
166179
"user": user
167180
},
@@ -226,6 +239,7 @@ async def validation_exception_handler(
226239
"errors/error.html",
227240
{
228241
"status_code": 422,
242+
"detail": None,
229243
"errors": errors,
230244
"user": user
231245
},
@@ -246,7 +260,7 @@ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
246260
return templates.TemplateResponse(
247261
request,
248262
"errors/error.html",
249-
{"status_code": exc.status_code, "detail": exc.detail, "user": user},
263+
{"status_code": exc.status_code, "detail": exc.detail, "errors": None, "user": user},
250264
status_code=exc.status_code,
251265
)
252266

@@ -271,6 +285,7 @@ async def general_exception_handler(request: Request, exc: Exception):
271285
{
272286
"status_code": 500,
273287
"detail": "Internal Server Error",
288+
"errors": None,
274289
"user": user
275290
},
276291
status_code=500,
@@ -283,14 +298,12 @@ async def general_exception_handler(request: Request, exc: Exception):
283298
@app.get("/")
284299
async def read_home(
285300
request: Request,
286-
user: Optional[User] = Depends(get_optional_user)
301+
_: None = Depends(require_unauthenticated_client)
287302
):
288-
if user:
289-
return RedirectResponse(url=app.url_path_for("read_dashboard"), status_code=302)
290303
return templates.TemplateResponse(
291304
request,
292305
"index.html",
293-
{"user": user}
306+
{"user": None}
294307
)
295308

296309

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "fastapi-jinja2-postgres-webapp"
3-
version = "0.1.11"
3+
version = "0.1.13"
44
description = "A template webapp with a pure-Python FastAPI backend, frontend templating with Jinja2, and a Postgres database to power user auth"
55
readme = "README.md"
66
package-mode = false
@@ -34,6 +34,7 @@ dev = [
3434
"sqlalchemy-schemadisplay<3.0,>=2.0",
3535
"ty>=0.0.21",
3636
"ruff>=0.15.5",
37+
"pytest-jinja-check[fastapi]>=1.0.2",
3738
]
3839

3940
[tool.ty.rules]

0 commit comments

Comments
 (0)