Skip to content

Commit 5efc3e5

Browse files
Merge remote-tracking branch 'origin/main' into modal
2 parents 7e36a5d + 72cf223 commit 5efc3e5

29 files changed

Lines changed: 804 additions & 138 deletions

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
"Find",
1212
"Search",
1313
"WebSearch",
14-
"WebFetch(*)"
14+
"WebFetch(*)",
15+
"Skill(browser-automation)",
16+
"Skill(browser-automation:*)"
1517
]
1618
}
1719
}

.github/workflows/test.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,14 @@ jobs:
5151
- name: Run type checking with ty
5252
run: uv run ty check .
5353

54+
- name: Install Playwright browsers
55+
run: uv run playwright install --with-deps chromium
56+
5457
- name: Run tests with pytest
58+
env:
59+
DB_HOST: localhost
60+
DB_PORT: "5432"
61+
DB_NAME: db
62+
DB_USER: postgres
63+
DB_PASSWORD: postgres
5564
run: uv run pytest tests/

docs/customization.qmd

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,17 @@ graph.write_png('static/schema.png')
247247

248248
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.
249249

250+
### Example application data model
251+
252+
The template ships with an illustrative data model, `OrganizationResource`, in `utils/app/models.py`. This model demonstrates how to create an organization-scoped database table and is used by the dashboard to display example resources. It is **meant to be replaced** with your own application-specific models.
253+
254+
To replace it:
255+
256+
1. Edit `utils/app/models.py` — remove `OrganizationResource` and define your own SQLModel table classes. Any table class defined in this file will be automatically created in the database on startup.
257+
2. Update `routers/core/dashboard.py` — replace the `OrganizationResource` query and template context with your own data.
258+
3. Update `templates/dashboard/index.html` — replace the example resource list with your own application UI.
259+
4. Optionally add new permission values to `AppPermissions` in `utils/app/enums.py` if your models need custom permission checks. These are automatically registered alongside the core `ValidPermissions` during database setup.
260+
250261
### Database helpers
251262

252263
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:
@@ -266,16 +277,20 @@ async def get_users(session: Session = Depends(get_session)):
266277

267278
The session automatically handles transaction management, ensuring that database operations are atomic and consistent.
268279

269-
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:
280+
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 `StrEnum` value (either from `ValidPermissions` in `utils/core/enums.py` or `AppPermissions` in `utils/app/enums.py`), and its second argument must be an `Organization` object or an `int` representing an organization ID:
270281

271282
```python
272-
permission = ValidPermissions.CREATE_ROLE
273-
organization = session.exec(select(Organization).where(Organization.name == "Acme Inc.")).first()
283+
from utils.core.enums import ValidPermissions
284+
from utils.app.enums import AppPermissions
285+
286+
# Check a core permission
287+
user.has_permission(ValidPermissions.CREATE_ROLE, organization)
274288

275-
user.has_permission(permission, organization)
289+
# Check an app-specific permission
290+
user.has_permission(AppPermissions.READ_ORGANIZATION_RESOURCES, organization)
276291
```
277292

278-
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.
293+
Core permissions used by the template's built-in features are defined in `ValidPermissions` (`utils/core/enums.py`). To add your own app-specific permissions, define them in `AppPermissions` (`utils/app/enums.py`). Both enum types are automatically registered in the database during setup and work interchangeably with `User.has_permission()`.
279294

280295
### Cascade deletes
281296

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.13"
3+
version = "0.1.14"
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
@@ -35,6 +35,7 @@ dev = [
3535
"ty>=0.0.21",
3636
"ruff>=0.15.5",
3737
"pytest-jinja-check[fastapi]>=1.0.2",
38+
"pytest-playwright>=0.7.2",
3839
]
3940

4041
[tool.ty.rules]

routers/core/dashboard.py

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
from typing import Optional
2-
from fastapi import APIRouter, Depends, Request
1+
from typing import Optional, List
2+
from fastapi import APIRouter, Depends, Request, Response
33
from fastapi.templating import Jinja2Templates
4-
from utils.core.dependencies import get_user_with_relations
5-
from utils.core.models import User
4+
from sqlmodel import Session, select
5+
from utils.core.dependencies import get_user_with_relations, get_session
6+
from utils.core.models import User, Organization
7+
from utils.app.enums import AppPermissions
8+
from utils.app.models import OrganizationResource
69

710
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
811
templates = Jinja2Templates(directory="templates")
@@ -14,10 +17,85 @@
1417
@router.get("/")
1518
async def read_dashboard(
1619
request: Request,
17-
user: Optional[User] = Depends(get_user_with_relations)
20+
user: User = Depends(get_user_with_relations),
21+
session: Session = Depends(get_session),
1822
):
23+
organizations = user.organizations
24+
selected_org: Optional[Organization] = None
25+
resources: List[OrganizationResource] = []
26+
can_read = False
27+
can_write = False
28+
can_delete = False
29+
30+
if organizations:
31+
# Read selected org from cookie, fall back to first org
32+
selected_org_id_str = request.cookies.get("selected_organization_id")
33+
if selected_org_id_str:
34+
try:
35+
selected_org_id = int(selected_org_id_str)
36+
selected_org = next(
37+
(o for o in organizations if o.id == selected_org_id), None
38+
)
39+
except ValueError:
40+
pass
41+
42+
if not selected_org:
43+
selected_org = organizations[0]
44+
45+
# Load organization resources for the selected org
46+
if selected_org and selected_org.id is not None:
47+
resources = list(session.exec(
48+
select(OrganizationResource)
49+
.where(OrganizationResource.organization_id == selected_org.id)
50+
.order_by(OrganizationResource.created_at.desc()) # type: ignore[union-attr]
51+
).all())
52+
can_read = user.has_permission(
53+
AppPermissions.READ_ORGANIZATION_RESOURCES, selected_org
54+
)
55+
can_write = user.has_permission(
56+
AppPermissions.WRITE_ORGANIZATION_RESOURCES, selected_org
57+
)
58+
can_delete = user.has_permission(
59+
AppPermissions.DELETE_ORGANIZATION_RESOURCES, selected_org
60+
)
61+
1962
return templates.TemplateResponse(
2063
request,
2164
"dashboard/index.html",
22-
{"user": user}
23-
)
65+
{
66+
"user": user,
67+
"organizations": organizations,
68+
"selected_org": selected_org,
69+
"resources": resources,
70+
"can_read": can_read,
71+
"can_write": can_write,
72+
"can_delete": can_delete,
73+
}
74+
)
75+
76+
77+
@router.post("/select-organization/{org_id}")
78+
async def select_organization(
79+
request: Request,
80+
org_id: int,
81+
user: User = Depends(get_user_with_relations),
82+
):
83+
"""Set the selected organization cookie and redirect back to dashboard."""
84+
# Verify user is a member of this organization
85+
org = next((o for o in user.organizations if o.id == org_id), None)
86+
if not org:
87+
# Fall back to dashboard without changing cookie
88+
response = Response(status_code=200)
89+
response.headers["HX-Redirect"] = str(request.url_for("read_dashboard"))
90+
return response
91+
92+
response = Response(status_code=200)
93+
response.set_cookie(
94+
key="selected_organization_id",
95+
value=str(org_id),
96+
httponly=True,
97+
samesite="strict",
98+
max_age=60 * 60 * 24 * 365, # 1 year
99+
)
100+
response.headers["HX-Redirect"] = str(request.url_for("read_dashboard"))
101+
return response

routers/core/invitation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from logging import getLogger
1010

1111
from utils.core.dependencies import get_authenticated_user, get_optional_user, get_session
12-
from utils.core.models import User, Role, Account, Invitation, ValidPermissions, Organization
12+
from utils.core.models import User, Role, Account, Invitation, Organization
13+
from utils.core.enums import ValidPermissions
1314
from utils.core.invitations import send_invitation_email, process_invitation
1415
from exceptions.http_exceptions import (
1516
UserIsAlreadyMemberError,

routers/core/organization.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from utils.core.dependencies import get_authenticated_user, get_user_with_relations, get_session
1010
from utils.core.models import Organization, User, Role, Account, utc_now, Invitation
1111
from utils.core.enums import ValidPermissions
12+
from utils.app.enums import AppPermissions
1213
from exceptions.http_exceptions import (
1314
OrganizationNotFoundError, OrganizationNameTakenError,
1415
InsufficientPermissionsError, OrganizationSetupError,
@@ -71,6 +72,7 @@ async def read_organization(
7172
"user": user,
7273
"user_permissions": user_permissions,
7374
"ValidPermissions": ValidPermissions,
75+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
7476
"active_invitations": active_invitations
7577
}
7678
)

routers/core/role.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from sqlalchemy.orm import selectinload
1010
from sqlalchemy.exc import IntegrityError
1111
from utils.core.dependencies import get_authenticated_user, get_session
12-
from utils.core.models import Role, Permission, ValidPermissions, utc_now, User, DataIntegrityError, Organization
12+
from utils.core.models import Role, Permission, utc_now, User, DataIntegrityError, Organization
13+
from utils.core.enums import ValidPermissions
14+
from utils.app.enums import AppPermissions
1315
from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError, CannotModifyDefaultRoleError
1416
from routers.core.organization import router as organization_router
1517
from utils.core.htmx import is_htmx_request, append_toast
@@ -45,7 +47,7 @@ def create_role(
4547
request: Request,
4648
name: Annotated[str, Form(min_length=1, strip_whitespace=True, title="Role name", description="Name for the new role")],
4749
organization_id: int = Form(..., title="Organization ID", description="ID of the organization this role belongs to"),
48-
permissions: List[ValidPermissions] = Form(default=[], title="Permissions", description="List of permissions to assign to this role"),
50+
permissions: List[str] = Form(default=[], title="Permissions", description="List of permissions to assign to this role"),
4951
user: User = Depends(get_authenticated_user),
5052
session: Session = Depends(get_session)
5153
):
@@ -94,6 +96,7 @@ def create_role(
9496
"user": user,
9597
"user_permissions": user_permissions,
9698
"ValidPermissions": ValidPermissions,
99+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
97100
},
98101
)
99102
response.headers["HX-Trigger"] = "modalDismiss"
@@ -110,7 +113,7 @@ def update_role(
110113
id: int = Form(..., title="Role ID", description="ID of the role to update"),
111114
name: str = Form(..., min_length=1, strip_whitespace=True, title="Role name", description="Updated name for the role"),
112115
organization_id: int = Form(..., title="Organization ID", description="ID of the organization this role belongs to"),
113-
permissions: List[ValidPermissions] = Form(default=[], title="Permissions", description="Updated list of permissions for this role"),
116+
permissions: List[str] = Form(default=[], title="Permissions", description="Updated list of permissions for this role"),
114117
user: User = Depends(get_authenticated_user),
115118
session: Session = Depends(get_session)
116119
):
@@ -132,8 +135,9 @@ def update_role(
132135
raise CannotModifyDefaultRoleError(action="update")
133136

134137
# If any user-selected permissions are not valid, raise an error
138+
all_valid = {str(p) for p in ValidPermissions} | {str(p) for p in AppPermissions}
135139
for permission in permissions:
136-
if permission not in ValidPermissions:
140+
if permission not in all_valid:
137141
raise InvalidPermissionError(permission)
138142

139143
# Add any user-selected permissions that are not already associated with the role
@@ -184,6 +188,7 @@ def update_role(
184188
"user": user,
185189
"user_permissions": user_permissions,
186190
"ValidPermissions": ValidPermissions,
191+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
187192
},
188193
)
189194
response.headers["HX-Trigger"] = "modalDismiss"
@@ -238,6 +243,7 @@ def delete_role(
238243
"user": user,
239244
"user_permissions": user_permissions,
240245
"ValidPermissions": ValidPermissions,
246+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
241247
},
242248
)
243249
return append_toast(response, request, templates, "Role deleted successfully.")

routers/core/user.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from utils.core.dependencies import get_authenticated_user, get_user_with_relations, get_session
99
from utils.core.images import validate_and_process_image, MAX_FILE_SIZE, MIN_DIMENSION, MAX_DIMENSION, ALLOWED_CONTENT_TYPES
1010
from utils.core.enums import ValidPermissions
11+
from utils.app.enums import AppPermissions
1112
from exceptions.http_exceptions import (
1213
InsufficientPermissionsError,
1314
UserNotFoundError,
@@ -192,6 +193,7 @@ def update_user_role(
192193
"user": user,
193194
"user_permissions": user_permissions,
194195
"ValidPermissions": ValidPermissions,
196+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
195197
},
196198
)
197199
response.headers["HX-Trigger"] = "modalDismiss"
@@ -259,6 +261,7 @@ def remove_user_from_organization(
259261
"user": user,
260262
"user_permissions": user_permissions,
261263
"ValidPermissions": ValidPermissions,
264+
"all_permissions": list(ValidPermissions) + list(AppPermissions),
262265
},
263266
)
264267
return append_toast(response, request, templates, "User removed from organization.")

static/js/app.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// app.js — global utilities loaded with defer in <head>.
2+
// Because this script lives in <head> (outside <body>), it is never
3+
// re-processed during htmx hx-boost body swaps, which means the event
4+
// listeners registered here persist across page navigations.
5+
6+
function showToast(message, level) {
7+
level = level || 'success';
8+
var container = document.getElementById('toast-container');
9+
var wrapper = document.createElement('div');
10+
wrapper.className = 'toast align-items-center text-bg-' + level + ' border-0 show';
11+
wrapper.setAttribute('role', 'alert');
12+
wrapper.setAttribute('aria-atomic', 'true');
13+
wrapper.innerHTML =
14+
'<div class="d-flex">' +
15+
'<div class="toast-body">' + message + '</div>' +
16+
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>' +
17+
'</div>';
18+
container.appendChild(wrapper);
19+
setTimeout(function() { wrapper.remove(); }, 5000);
20+
}
21+
22+
// For HTMX error responses, extract and apply OOB swaps (toasts) without
23+
// touching the main target. We parse the response HTML, find elements with
24+
// hx-swap-oob, and swap them in manually via htmx.process().
25+
document.body.addEventListener('htmx:beforeSwap', function(evt) {
26+
if (evt.detail.xhr.status >= 400) {
27+
evt.detail.shouldSwap = false;
28+
evt.detail.isError = false;
29+
var responseText = evt.detail.xhr.responseText;
30+
if (responseText) {
31+
var doc = new DOMParser().parseFromString(responseText, 'text/html');
32+
var oobElements = doc.querySelectorAll('[hx-swap-oob]');
33+
oobElements.forEach(function(el) {
34+
var targetId = el.getAttribute('id');
35+
if (targetId) {
36+
var existing = document.getElementById(targetId);
37+
if (existing) {
38+
existing.replaceWith(el);
39+
htmx.process(el);
40+
}
41+
}
42+
});
43+
}
44+
}
45+
});
46+
47+
// Read flash cookie on page load
48+
(function() {
49+
var raw = document.cookie.split('; ').find(function(c) { return c.startsWith('flash_message='); });
50+
if (!raw) return;
51+
var value = decodeURIComponent(raw.split('=').slice(1).join('='));
52+
document.cookie = 'flash_message=; Max-Age=0; path=/';
53+
try {
54+
var flash = JSON.parse(value);
55+
if (flash && flash.message) showToast(flash.message, flash.level);
56+
} catch(e) {}
57+
})();
58+
59+
// Global handler: when a server response includes HX-Trigger: modalDismiss,
60+
// clean up any Bootstrap modal backdrop left behind by OOB swaps that
61+
// replaced the modal element before afterRequest could call .hide().
62+
document.body.addEventListener('modalDismiss', function() {
63+
document.querySelectorAll('.modal-backdrop').forEach(function(el) { el.remove(); });
64+
document.body.classList.remove('modal-open');
65+
document.body.style.removeProperty('overflow');
66+
document.body.style.removeProperty('padding-right');
67+
});

0 commit comments

Comments
 (0)