Skip to content

Commit 71d835a

Browse files
Refactor to separate core permissions from app permissions
1 parent 72a22b2 commit 71d835a

17 files changed

Lines changed: 125 additions & 65 deletions

File tree

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

routers/core/dashboard.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from sqlmodel import Session, select
55
from utils.core.dependencies import get_user_with_relations, get_session
66
from utils.core.models import User, Organization
7-
from utils.core.enums import ValidPermissions
7+
from utils.app.enums import AppPermissions
88
from utils.app.models import OrganizationResource
99

1010
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -50,13 +50,13 @@ async def read_dashboard(
5050
.order_by(OrganizationResource.created_at.desc()) # type: ignore[union-attr]
5151
).all())
5252
can_read = user.has_permission(
53-
ValidPermissions.READ_ORGANIZATION_RESOURCES, selected_org
53+
AppPermissions.READ_ORGANIZATION_RESOURCES, selected_org
5454
)
5555
can_write = user.has_permission(
56-
ValidPermissions.WRITE_ORGANIZATION_RESOURCES, selected_org
56+
AppPermissions.WRITE_ORGANIZATION_RESOURCES, selected_org
5757
)
5858
can_delete = user.has_permission(
59-
ValidPermissions.DELETE_ORGANIZATION_RESOURCES, selected_org
59+
AppPermissions.DELETE_ORGANIZATION_RESOURCES, selected_org
6060
)
6161

6262
return templates.TemplateResponse(

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.")

templates/organization/modals/roles_card.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ <h5 class="modal-title" id="createRoleModalLabel">Create New Role</h5>
9696
<div class="mb-3">
9797
<label class="form-label">Permissions</label>
9898
<div class="row">
99-
{% for permission in ValidPermissions %}
99+
{% for permission in all_permissions %}
100100
<div class="col-md-6 mb-2">
101101
<div class="form-check">
102102
<input class="form-check-input" type="checkbox" name="permissions" value="{{ permission.value }}" id="perm_{{ permission.value | replace(' ', '_') }}">
@@ -144,7 +144,7 @@ <h5 class="modal-title" id="editRoleModalLabel{{ role.id }}">Edit Role: {{ role.
144144
<label class="form-label">Permissions</label>
145145
<div class="row">
146146
{% set role_permission_names = role.permissions | map(attribute='name') | list %}
147-
{% for permission in ValidPermissions %}
147+
{% for permission in all_permissions %}
148148
<div class="col-md-6 mb-2">
149149
<div class="form-check">
150150
<input class="form-check-input" type="checkbox" name="permissions" value="{{ permission.value }}"

templates/organization/partials/roles_table.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ <h5 class="modal-title" id="editRoleModalLabel{{ role.id }}">Edit Role: {{ role.
8080
<label class="form-label">Permissions</label>
8181
<div class="row">
8282
{% set role_permission_names = role.permissions | map(attribute='name') | list %}
83-
{% for permission in ValidPermissions %}
83+
{% for permission in all_permissions %}
8484
<div class="col-md-6 mb-2">
8585
<div class="form-check">
8686
<input class="form-check-input" type="checkbox" name="permissions" value="{{ permission.value }}"

tests/routers/core/test_invitation.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from unittest.mock import MagicMock
44
from sqlmodel import Session, select
55
from tests.conftest import SetupError
6-
from utils.core.models import Role, Permission, ValidPermissions, User, Invitation, Organization, Account
6+
from utils.core.models import Role, Permission, User, Invitation, Organization, Account
7+
from utils.core.enums import ValidPermissions
78
from main import app
89
from utils.core.invitations import generate_invitation_link
910

@@ -15,7 +16,7 @@ def invite_user_permission(session: Session) -> Permission:
1516
).first()
1617
if permission is None:
1718
# Attempt to create it if missing. Permissions are global.
18-
permission = Permission(name=ValidPermissions.INVITE_USER)
19+
permission = Permission(name=str(ValidPermissions.INVITE_USER))
1920
session.add(permission)
2021
session.commit()
2122
session.refresh(permission)

tests/routers/core/test_organization.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from utils.core.models import Organization, Role, Permission, ValidPermissions, User
1+
from utils.core.models import Organization, Role, Permission, User
2+
from utils.core.enums import ValidPermissions
23
from utils.core.db import create_default_roles
34
from main import app
45
from sqlmodel import select

0 commit comments

Comments
 (0)