Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4060392
Record consent with a timestamp on participant data
barry47products Aug 27, 2026
2fb95c7
Key consent enforcement on the widget release that collects it
barry47products Aug 27, 2026
ecade0f
Describe a session's consent state for the Chat API
barry47products Aug 27, 2026
cd31928
Report the consent state when a chat session starts
barry47products Aug 27, 2026
9c166cc
Report the consent state on every poll
barry47products Aug 27, 2026
6dd1948
Regenerate the schema without the local OIDC scopes
barry47products Aug 27, 2026
c1fdb4f
Record consent through the Chat API
barry47products Aug 27, 2026
cacce4f
Refuse consent on a completed session like the other chat endpoints
barry47products Aug 27, 2026
a97f4cd
Hold messages and uploads until consent is recorded
barry47products Aug 27, 2026
8443ea1
Skip the participant data query when a version has no consent form
barry47products Aug 27, 2026
615306a
Cover the frozen consent form and the consent endpoint's access control
barry47products Aug 27, 2026
a503dd6
Key consent on the accepted form version
barry47products Aug 27, 2026
b66ff35
Gate consent enforcement on widget 0.13.0
barry47products Aug 27, 2026
5171cd0
Merge branch 'main' into bt/consent-backend-3682
barry47products Aug 27, 2026
86bf68a
Keep the first acceptance time on a repeated consent POST
barry47products Aug 27, 2026
6f41d28
Merge branch 'main' into bt/consent-backend-3682
barry47products Aug 31, 2026
33b05ad
Merge remote-tracking branch 'origin/main' into bt/consent-backend-3682
barry47products Aug 31, 2026
d11e023
Merge branch 'main' into bt/consent-backend-3682
snopoke Sep 2, 2026
2b4cb31
Revert type change
snopoke Sep 2, 2026
0a7f5c4
Resolve consent on the session's own version
github-actions[bot] Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions api-schemas/v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,49 @@ paths:
schema:
$ref: '#/components/schemas/ChatTaskPollError'
description: ''
/api/chat/{session_id}/consent/:
post:
operationId: chat_record_consent
description: |-
Record consent for the form version the participant was shown.

A `form_version_id` that is not the session version's current form is refused with `409`
and the current block, so the widget re-renders rather than recording consent to text the
participant never saw.
summary: Record that the participant accepted the chatbot's consent form
parameters:
- in: path
name: session_id
schema:
type: string
description: Session ID
required: true
tags:
- Chat
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ChatConsentRequest'
required: true
security:
- cookieAuth: []
- embedKeyAuth: []
responses:
'204':
description: No response body
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ChatConsentSessionEnded'
description: ''
'409':
content:
application/json:
schema:
$ref: '#/components/schemas/ChatConsentStale'
Comment thread
barry47products marked this conversation as resolved.
description: ''
/api/chat/{session_id}/message/:
post:
operationId: chat_send_message
Expand Down Expand Up @@ -93,6 +136,12 @@ paths:
schema:
$ref: '#/components/schemas/ChatSendMessageResponse'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/ChatConsentRequired'
description: ''
/api/chat/{session_id}/poll/:
get:
operationId: chat_poll_response
Expand Down Expand Up @@ -158,6 +207,12 @@ paths:
schema:
$ref: '#/components/schemas/ChatUploadFileResponse'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/ChatConsentRequired'
description: ''
/api/chat/start/:
post:
operationId: chat_start_session
Expand Down Expand Up @@ -213,6 +268,10 @@ paths:
participant:
identifier: abc
remote_id: abc
consent:
required: false
form_version_id: null
text: null
summary: Session started with published version
StartSessionSpecificVersionResponse:
value:
Expand All @@ -226,6 +285,10 @@ paths:
participant:
identifier: abc
remote_id: abc
consent:
required: false
form_version_id: null
text: null
summary: Session started with specific version
description: ''
'401':
Expand Down Expand Up @@ -1111,6 +1174,77 @@ components:
- assistant
type: string
description: '* `assistant` - assistant'
ChatConsent:
type: object
properties:
required:
type: boolean
title: Consent required
description: True until the participant accepts the chatbot's consent form.
Send and upload return `403 consent_required` while this is true; poll
is never gated.
form_version_id:
type: integer
nullable: true
title: Consent form version ID
description: Identifies the frozen consent form text. Post it back to `/consent/`.
A changed form gets a new id, and consent is required again until the
participant accepts it.
text:
type: string
nullable: true
title: Consent text
description: Rendered HTML of the consent form. Present only while consent
is required.
required:
- form_version_id
- required
- text
ChatConsentRequest:
type: object
properties:
form_version_id:
type: integer
title: Consent form version ID
description: The `form_version_id` from the start or poll response being
accepted.
required:
- form_version_id
ChatConsentRequired:
type: object
properties:
error:
type: string
code:
type: string
description: '`consent_required`.'
consent:
$ref: '#/components/schemas/ChatConsent'
required:
- code
- consent
- error
ChatConsentSessionEnded:
type: object
properties:
error:
type: string
required:
- error
ChatConsentStale:
type: object
properties:
error:
type: string
code:
type: string
description: Always `consent_stale`.
consent:
$ref: '#/components/schemas/ChatConsent'
required:
- code
- consent
- error
ChatPollResponse:
type: object
properties:
Expand All @@ -1126,7 +1260,12 @@ components:
allOf:
- $ref: '#/components/schemas/SessionStatusEnum'
title: Current session status
consent:
allOf:
- $ref: '#/components/schemas/ChatConsent'
readOnly: true
required:
- consent
- has_more
- messages
- session_status
Expand Down Expand Up @@ -1242,8 +1381,13 @@ components:
allOf:
- $ref: '#/components/schemas/Participant'
readOnly: true
consent:
allOf:
- $ref: '#/components/schemas/ChatConsent'
readOnly: true
required:
- chatbot
- consent
- participant
- session_id
ChatTaskPoll:
Expand Down
59 changes: 59 additions & 0 deletions apps/api/chat_consent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Consent as the Chat API reports and enforces it (public channel design, D7).

The store is ``ParticipantData.system_metadata["consent"]``, shared with CommCare Connect and read
by ``ConsentCheckStage``, plus the accepted ``consent_form_version_id``. The text is the frozen
``ConsentForm`` on the version the session runs against; a republished form has a new id, so the
participant is prompted again until they accept it.
"""

from rest_framework import status
from rest_framework.response import Response

from apps.channels.widget_versions import WIDGET_VERSION_HEADER, widget_enforces_consent
from apps.experiments.models import Experiment, ExperimentSession, ParticipantData


def participant_data_for(session: ExperimentSession) -> ParticipantData | None:
return ParticipantData.objects.for_experiment(session.experiment).filter(participant=session.participant).first()


def consent_block(version: Experiment, participant_data: ParticipantData | None) -> dict:
form = version.consent_form
if form is None:
return {"required": False, "form_version_id": None, "text": None}
consented = participant_data is not None and participant_data.has_consented_to(form.id)
return {
"required": not consented,
"form_version_id": form.id,
"text": None if consented else form.get_rendered_content(),
}


def session_consent_block(session: ExperimentSession) -> dict:
"""The consent block for `session`, without a participant-data query when there is no form.

Always the session's own version, never a `version_number` the caller asked to preview:
participant data holds one accepted form id and the consent endpoint has no version to key
on, so a preview gated on the previewed version's form could never be satisfied.
"""
version = session.experiment_version
if version.consent_form_id is None:
return consent_block(version, None)
return consent_block(version, participant_data_for(session))


def consent_refusal(request, session: ExperimentSession) -> Response | None:
"""The 403 that holds a message until consent is recorded, or None.

Only widgets from ``CONSENT_INTRODUCED`` on are refused: older widgets treat every 403 as a
dead session, and non-widget API callers have no consent surface.
"""
if not widget_enforces_consent(request.headers.get(WIDGET_VERSION_HEADER)):
return None
block = session_consent_block(session)
if not block["required"]:
return None
return Response(
{"error": "Consent is required before chatting", "code": "consent_required", "consent": block},
status=status.HTTP_403_FORBIDDEN,
)
28 changes: 28 additions & 0 deletions apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,32 @@ def validate_timezone(self, value):
return value if value and value in available_timezones() else None


class ChatConsentSerializer(serializers.Serializer):
required = serializers.BooleanField(
label="Consent required",
help_text="True until the participant accepts the chatbot's consent form. Send and upload return"
" `403 consent_required` while this is true; poll is never gated.",
)
form_version_id = serializers.IntegerField(
label="Consent form version ID",
allow_null=True,
help_text="Identifies the frozen consent form text. Post it back to `/consent/`. A changed form"
" gets a new id, and consent is required again until the participant accepts it.",
)
text = serializers.CharField(
label="Consent text",
allow_null=True,
help_text="Rendered HTML of the consent form. Present only while consent is required.",
)


class ChatConsentRequest(serializers.Serializer):
form_version_id = serializers.IntegerField(
label="Consent form version ID",
help_text="The `form_version_id` from the start or poll response being accepted.",
)


class ChatStartSessionResponse(serializers.Serializer):
session_id = serializers.UUIDField(label="Session ID")
session_token = serializers.CharField(
Expand All @@ -360,6 +386,7 @@ class ChatStartSessionResponse(serializers.Serializer):
)
chatbot = ExperimentSerializer(read_only=True)
participant = ParticipantSerializer(read_only=True)
consent = ChatConsentSerializer(read_only=True)


class ChatSendMessageRequest(serializers.Serializer):
Expand Down Expand Up @@ -390,6 +417,7 @@ class ChatPollResponse(serializers.Serializer):
session_status = serializers.ChoiceField(
choices=[("active", "Active"), ("ended", "Ended")], label="Current session status"
)
consent = ChatConsentSerializer(read_only=True)


class TriggerBotMessageRequest(serializers.Serializer):
Expand Down
23 changes: 22 additions & 1 deletion apps/api/tests/test_chat_api_anon.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ def test_start_chat_session(team_with_users, api_client, experiment):
"versions": [],
},
"participant": {"identifier": mock.ANY, "remote_id": ""},
"consent": {
"required": True,
"form_version_id": experiment.consent_form_id,
"text": experiment.consent_form.get_rendered_content(),
},
}
assert response_json["session_token"] # token must be non-null
assert response_json["participant"]["identifier"].startswith("anon:")
Expand Down Expand Up @@ -147,7 +152,16 @@ def test_session_poll(api_client, session):
url = reverse("api:chat:poll-response", kwargs={"session_id": session.external_id})
response = api_client.get(url)
response_json = response.json()
assert response_json == {"has_more": False, "messages": [], "session_status": "active"}
assert response_json == {
"has_more": False,
"messages": [],
"session_status": "active",
"consent": {
"required": True,
"form_version_id": session.experiment.consent_form_id,
"text": session.experiment.consent_form.get_rendered_content(),
},
}


@pytest.mark.django_db()
Expand Down Expand Up @@ -188,17 +202,24 @@ def test_session_poll_with_messages(api_client, session):
"tags": ["test"],
},
]
expected_consent = {
"required": True,
"form_version_id": session.experiment.consent_form_id,
"text": session.experiment.consent_form.get_rendered_content(),
}
assert response.json() == {
"has_more": False,
"messages": expected_messages,
"session_status": "active",
"consent": expected_consent,
}

response = api_client.get(url, data={"limit": 1})
assert response.json() == {
"has_more": True,
"messages": [expected_messages[0]],
"session_status": "active",
"consent": expected_consent,
}


Expand Down
Loading
Loading