Inductions¶
Status: Specified — ready to build Build order: 3 of 10 — depends on Projects & Sites, Personnel & Attendance
Purpose¶
Inductions ensures every worker has read and acknowledged site safety rules before being allowed onto a site for the first time. A site manager creates an induction template containing the briefing content and any declaration statements. Workers complete it digitally and capture a signature. The completion is the gate for the Personnel sign-in flow.
Entities¶
InductionTemplate¶
One active template per site. Defines the content and required declarations. When the content is updated a new version is created — old completions remain valid against the version at the time of completion.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| site_id | UUID | ✓ | FK → sites.id |
| project_id | UUID | ✓ | Denormalised for query efficiency |
| title | VARCHAR(255) | ✓ | Displayed to workers on the completion screen |
| content | TEXT | ✓ | Briefing text — supports Markdown |
| declarations | JSONB | ✓ | Ordered list of statement strings the worker must tick |
| signature_required | BOOLEAN | ✓ | Default: true |
| refresh_days | INTEGER | NULL = never expires. 90 / 180 / 365 = worker must redo after N days | |
| version | INTEGER | ✓ | Default: 1. Increments on each publish |
| is_active | BOOLEAN | ✓ | Default: true. Only one active template per site |
| invalidate_on_new_version | BOOLEAN | ✓ | Default: false. If true, publishing a new version voids all existing completions |
| custom_fields | JSONB | ✓ | Default {} |
| created_at | TIMESTAMPTZ | System | |
| updated_at | TIMESTAMPTZ | System | Trigger-maintained |
| created_by | UUID | System | FK → users.id |
| updated_by | UUID | System | |
| deleted_at | TIMESTAMPTZ |
-- Only one active template per site
CREATE UNIQUE INDEX idx_induction_template_active
ON induction_templates(site_id) WHERE is_active = true AND deleted_at IS NULL;
CREATE INDEX idx_induction_template_site
ON induction_templates(site_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_induction_template_project
ON induction_templates(project_id) WHERE deleted_at IS NULL;
Declarations format:
[
"I have read and understood the site induction briefing",
"I am aware of the emergency assembly point and evacuation procedure",
"I confirm I will wear the required PPE at all times on site",
"I understand that breaching site safety rules may result in removal from site"
]
Each declaration renders as a required checkbox on the completion form.
InductionCompletion¶
A record of a worker completing a specific induction template. One completion
per (worker, template) pair. If invalidate_on_new_version = true, old completions
are soft-deleted when a new version is published.
| Field | Type | Required | Notes |
|---|---|---|---|
| id | UUID | System | PK, generated |
| template_id | UUID | ✓ | FK → induction_templates.id |
| template_version | INTEGER | ✓ | Snapshot of template.version at time of completion |
| personnel_id | UUID | ✓ | FK → personnel.id |
| site_id | UUID | ✓ | Denormalised from template |
| project_id | UUID | ✓ | Denormalised |
| completed_at | TIMESTAMPTZ | ✓ | |
| completed_by_method | VARCHAR(20) | ✓ | self | witnessed |
| witnessed_by | UUID | FK → users.id — site manager who witnessed in-person completion | |
| declarations_accepted | BOOLEAN[] | ✓ | One boolean per declaration, in order |
| signature_key | VARCHAR(500) | S3 key for drawn signature image — required if signature_required = true |
|
| expires_at | TIMESTAMPTZ | Computed: completed_at + template.refresh_days DAYS. NULL if no refresh |
|
| notes | TEXT | ||
| created_at | TIMESTAMPTZ | System | |
| updated_at | TIMESTAMPTZ | System | |
| deleted_at | TIMESTAMPTZ | NULL = valid. Set to NOW() when invalidated |
-- Valid completion lookup — used on every sign-in
CREATE INDEX idx_induction_completion_lookup
ON induction_completions(personnel_id, site_id)
WHERE deleted_at IS NULL;
CREATE INDEX idx_induction_completion_template
ON induction_completions(template_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_induction_completion_project
ON induction_completions(project_id) WHERE deleted_at IS NULL;
-- Refresh expiry scanner
CREATE INDEX idx_induction_completion_expires
ON induction_completions(expires_at)
WHERE expires_at IS NOT NULL AND deleted_at IS NULL;
Sign-in Check Interface¶
The Personnel module calls this function during the sign-in flow.
async def has_completed_induction(
personnel_id: UUID,
site_id: UUID,
db: AsyncSession,
) -> bool:
"""
Returns True if:
- No active InductionTemplate exists for the site (no induction configured), OR
- A valid InductionCompletion exists for this worker at this site:
- deleted_at IS NULL
- expires_at IS NULL OR expires_at > NOW()
Returns False if an active template exists but no valid completion does.
"""
Build with a stub returning True during Personnel development. Replace with
this real implementation when Inductions is built. The stub lives in
apps/api/src/modules/inductions/service.py and is imported by the Personnel
sign-in service.
Business Rules¶
-
Only one InductionTemplate can be active (
is_active = true) per site at a time. Creating a new version deactivates the previous one. -
A worker's completion remains valid until:
expires_atpasses (ifrefresh_daysis set), OR- The template is updated with
invalidate_on_new_version = true(soft-deletes all completions for that template), OR -
A site manager explicitly invalidates a specific completion.
-
If no active template exists for a site,
has_completed_induction()returnsTrue. Sites are not forced to have an induction. -
The declarations array is ordered.
declarations_acceptedis a parallel boolean array. If any element isfalse, the completion is rejected — all declarations must be ticked. -
If
signature_required = true, asignature_keyis mandatory on the completion record. The signature is drawn on a canvas element on the worker's device or the kiosk tablet. -
completed_by_method = witnessedmeans a site manager watched the worker acknowledge in person and submitted the completion on their behalf.witnessed_bymust be set in this case. -
completed_by_method = selfmeans the worker completed it independently (kiosk screen or on their own device via QR link). -
A new version can be created as a draft (a separate future enhancement). In V1, publishing is immediate — create the new template, old one deactivated.
-
Workers completing on their own device use the same QR code scan URL as sign-in. The sign-in flow detects an incomplete induction and redirects to the induction completion form before allowing sign-in.
Induction Completion Flow¶
Worker scans site QR code (or site manager taps "Add induction")
1. Resolve site from QR token
2. Worker selects their name from the personnel register
3. System checks existing valid completion:
Valid completion found → proceed to sign-in flow
No valid completion found → present induction
4. Display induction content (title + briefing text)
5. Worker reads content (no enforced timer in V1)
6. Each declaration rendered as a required checkbox
7. Signature pad shown if signature_required = true
8. Worker submits
9. System validates: all declarations ticked, signature present if required
10. Write InductionCompletion record
completed_by_method = 'self'
completed_at = now()
expires_at = now() + refresh_days IF refresh_days NOT NULL
11. Redirect to sign-in flow → worker is now admitted
Site manager witnessed flow (in person):
Site Manager taps "Mark induction complete" for a worker
1. Select worker from personnel list
2. System shows declarations for confirmation
3. Site manager submits (no signature capture in witnessed mode in V1)
4. Write completion: completed_by_method = 'witnessed', witnessed_by = current_user
API Endpoints¶
Template management¶
| Method | Path | Description |
|---|---|---|
| GET | /sites/{siteId}/induction |
Get active template (or 404 if none) |
| POST | /sites/{siteId}/induction |
Create new template (deactivates current) |
| PATCH | /sites/{siteId}/induction |
Update active template content |
| GET | /sites/{siteId}/induction/history |
All versions for this site |
Completions¶
| Method | Path | Description |
|---|---|---|
| GET | /sites/{siteId}/induction/completions |
List completions for site (paginated) |
| POST | /sites/{siteId}/induction/complete |
Submit a completion |
| POST | /sites/{siteId}/induction/complete/witnessed |
Mark as complete on worker's behalf |
| DELETE | /sites/{siteId}/induction/completions/{id} |
Invalidate a completion |
| POST | /sites/{siteId}/induction/invalidate-all |
Invalidate all completions (on new version) |
| POST | /sites/{siteId}/induction/complete/signature |
Upload signature (returns presigned PUT URL) |
Project-level view¶
| Method | Path | Description |
|---|---|---|
| GET | /projects/{projectId}/inductions |
Completion status across all project sites |
| GET | /projects/{projectId}/inductions/pending |
Workers with no valid completion |
Request / Response Shapes¶
GET /sites/{siteId}/induction¶
// Response 200
{
"id": "uuid-template",
"site_id": "uuid-site",
"title": "Canary Wharf Phase 2 — Site Safety Induction",
"content": "## Emergency Procedures\n\nIn the event of a fire...\n\n## PPE Requirements\n\n...",
"declarations": [
"I have read and understood the site induction briefing",
"I am aware of the emergency assembly point and evacuation procedure",
"I confirm I will wear the required PPE at all times on site"
],
"signature_required": true,
"refresh_days": 180,
"version": 2,
"is_active": true,
"invalidate_on_new_version": false,
"completion_count": 34,
"created_at": "2026-05-28T09:00:00Z",
"updated_at": "2026-05-28T09:00:00Z"
}
// Response 404 — no active template
{ "error": "not_found", "message": "No active induction template for this site." }
POST /sites/{siteId}/induction/complete¶
// Request
{
"personnel_id": "uuid-worker",
"declarations_accepted": [true, true, true],
"signature_key": "tenants/uuid-tenant/signatures/uuid-sig.png"
}
// Response 201
{
"id": "uuid-completion",
"personnel_id": "uuid-worker",
"full_name": "James Brennan",
"site_id": "uuid-site",
"template_version": 2,
"completed_at": "2026-05-28T07:30:00Z",
"completed_by_method": "self",
"expires_at": "2026-11-28T07:30:00Z",
"signature_url": "https://...",
"valid": true
}
// Response 422 — not all declarations accepted
{
"error": "declarations_incomplete",
"message": "All declarations must be accepted before completing the induction.",
"rejected_indices": [2]
}
// Response 422 — signature missing
{
"error": "signature_required",
"message": "A signature is required to complete this induction."
}
GET /projects/{projectId}/inductions¶
// Response 200
{
"data": [
{
"site_id": "uuid-site",
"site_name": "Canary Wharf — Site A",
"has_template": true,
"template_version": 2,
"total_workers": 47,
"completed": 41,
"pending": 6,
"expired": 0
}
],
"pagination": { "total": 2, "cursor": null, "has_more": false }
}
GET /projects/{projectId}/inductions/pending¶
// Response 200
{
"data": [
{
"personnel_id": "uuid-worker",
"full_name": "Marcus Obi",
"employment_type": "direct",
"site_id": "uuid-site",
"site_name": "Canary Wharf — Site A",
"last_signed_in": null,
"completion_status": "never_completed"
},
{
"personnel_id": "uuid-worker-2",
"full_name": "Dan Pearce",
"employment_type": "subcontractor",
"site_id": "uuid-site",
"site_name": "Canary Wharf — Site A",
"last_signed_in": "2026-03-01T08:00:00Z",
"completion_status": "expired"
}
],
"pagination": { "total": 6, "cursor": null, "has_more": false }
}
completion_status values: never_completed | expired | invalidated
Permission Matrix¶
| Action | Platform Admin | Tenant Admin | Project Manager | Site Manager | Site Operative | Viewer |
|---|---|---|---|---|---|---|
| View template | ✓ | ✓ | ✓ assigned | ✓ own site | ✓ own site | ✓ |
| Create/edit template | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| View completions | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✓ |
| Complete own induction | — | — | — | — | ✓ | — |
| Mark as witnessed | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| Invalidate completion | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| Invalidate all (new version) | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✗ |
| View pending list | ✓ | ✓ | ✓ assigned | ✓ own site | ✗ | ✓ |
Notifications Triggered¶
| Event | Trigger point | Notification type |
|---|---|---|
| Worker blocked at sign-in (induction not done) | Personnel sign-in flow | induction.blocked_signin |
The notification is fired by the Personnel module's sign-in service when
has_completed_induction() returns False. The Inductions module does not
fire notifications directly.
No expiry scanner for inductions in V1. Expired completions are detected lazily at sign-in time (the worker gets blocked and is prompted to redo the induction).
Offline Sync Scope¶
| Data | Syncs to | Notes |
|---|---|---|
| Active InductionTemplate | Site Manager | Full content including declarations |
| InductionCompletions | Site Manager | Last 90 days for assigned sites |
| Pending workers (derived) | Not synced | Recomputed from local data |
Offline induction completion: - Worker completes induction on site manager's device (kiosk mode) - Completion written to local SQLite queue - Signature stored locally until upload - On reconnect: completion record synced to server, signature uploaded to S3 - Worker's sign-in proceeds offline immediately after local completion
If a worker completes offline and an invalidation was published while offline, the server returns a 409 on sync. The site manager sees a conflict flag and can re-do the induction manually.
Cross-Module Dependencies¶
Upstream¶
| Module | Dependency |
|---|---|
| Projects & Sites | project_id, site_id |
| Personnel & Attendance | personnel_id — workers must be in the register |
Downstream¶
| Module | Usage |
|---|---|
| Personnel & Attendance | Calls has_completed_induction() at every sign-in |
Tests Required¶
Template management¶
- Create template — valid payload → 201, previous template deactivated
- Create template — first template for site → 201,
is_active = true - Update template — PATCH content, version unchanged
- Get template — 200 with completion count
- Get template — no active template → 404
- History endpoint — returns all versions including deactivated
Completions¶
- Complete induction — all declarations accepted, signature provided → 201
- Complete induction — not all declarations accepted → 422
- Complete induction — signature missing when required → 422
- Complete induction — worker already has valid completion → 409
- Witnessed completion —
witnessed_byset, no signature required - Invalidate completion — soft-deletes,
has_completed_induction()now returnsFalse - Invalidate all — all completions for that template soft-deleted
expires_atcomputed correctly fromrefresh_days- Expired completion —
has_completed_induction()returnsFalse
Sign-in check interface¶
- Worker with valid completion →
has_completed_induction()returnsTrue - Worker with no completion →
has_completed_induction()returnsFalse - Worker with expired completion →
has_completed_induction()returnsFalse - Site with no active template →
has_completed_induction()returnsTrue - Worker with invalidated completion →
has_completed_induction()returnsFalse
Project-level view¶
/pendingreturns workers with no completion or expired completion- Summary counts accurate across multiple sites
Cross-tenant isolation (mandatory)¶
- GET template for site in another tenant → 404
- POST completion with
personnel_idfrom another tenant → 404 - POST completion for template in another tenant → 404
Permission boundaries¶
- Site Operative can view template, cannot manage it → 403 on PATCH
- Viewer cannot view completions list → 403 on completions endpoint
- Site Manager cannot manage template for unassigned site → 403
Default Tenant Labels¶
| Key | Default |
|---|---|
module.inductions |
Inductions |
field.induction.title |
Induction Title |
field.induction.content |
Briefing Content |
field.induction.declarations |
Declarations |
field.induction.signature_required |
Require Signature |
field.induction.refresh_days |
Refresh Period (days) |
status.induction.completed |
Completed |
status.induction.expired |
Expired |
status.induction.pending |
Pending |
Foundation Dependencies¶
| Component | Location | Used for |
|---|---|---|
TenantMiddleware |
core/middleware.py |
Sets request.state.tenant |
get_db |
core/db.py |
Schema-scoped AsyncSession |
get_current_user |
core/security.py |
Auth and role checks |
require_permission |
core/security.py |
RBAC dependency factory |
EntityMixin |
core/models.py |
Universal columns |
PaginatedResponse |
core/pagination.py |
List endpoint wrapper |
ConstruoError hierarchy |
core/exceptions.py |
Typed exceptions |
presigned_put_url |
core/storage.py |
Signature upload |
audit_entity_change trigger |
DB foundation | Applied to both tables |
conftest.py fixtures |
tests/conftest.py |
client, auth_headers, test_tenants |
factories.py |
tests/factories.py |
create_induction_template, create_induction_completion |