From 4060392c3e65fac6907b451fa9d7c351860afb4a Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 13:39:13 +0200 Subject: [PATCH 01/14] Record consent with a timestamp on participant data --- apps/experiments/models.py | 9 ++++ .../tests/test_participant_consent.py | 44 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 apps/experiments/tests/test_participant_consent.py diff --git a/apps/experiments/models.py b/apps/experiments/models.py index 011762ca91..b83d27378b 100644 --- a/apps/experiments/models.py +++ b/apps/experiments/models.py @@ -1337,6 +1337,15 @@ def update_consent(self, consent: bool): self.system_metadata["consent"] = consent self.save(update_fields=["system_metadata"]) + def record_consent(self) -> None: + """Record that the participant accepted the chatbot's consent form (D7 in the public channel design).""" + self.system_metadata = { + **self.system_metadata, + "consent": True, + "consent_at": timezone.now().isoformat(), + } + self.save(update_fields=["system_metadata"]) + class Meta: indexes = [ models.Index(fields=["experiment"]), diff --git a/apps/experiments/tests/test_participant_consent.py b/apps/experiments/tests/test_participant_consent.py new file mode 100644 index 0000000000..1e30219625 --- /dev/null +++ b/apps/experiments/tests/test_participant_consent.py @@ -0,0 +1,44 @@ +import pytest +from django.utils import timezone + +from apps.experiments.models import ParticipantData +from apps.utils.factories.experiment import ExperimentFactory, ParticipantFactory + + +@pytest.fixture() +def participant_data(team_with_users): + experiment = ExperimentFactory.create(team=team_with_users) + participant = ParticipantFactory.create(team=team_with_users) + return ParticipantData.objects.create(team=team_with_users, participant=participant, experiment=experiment) + + +@pytest.mark.django_db() +def test_record_consent_marks_the_participant_as_consented(participant_data): + assert not participant_data.has_consented() + + participant_data.record_consent() + + participant_data.refresh_from_db() + assert participant_data.has_consented() + + +@pytest.mark.django_db() +def test_record_consent_stamps_the_time_of_acceptance(participant_data): + before = timezone.now() + + participant_data.record_consent() + + participant_data.refresh_from_db() + consent_at = timezone.datetime.fromisoformat(participant_data.system_metadata["consent_at"]) + assert before <= consent_at <= timezone.now() + + +@pytest.mark.django_db() +def test_record_consent_keeps_other_system_metadata(participant_data): + participant_data.system_metadata = {"commcare_connect_channel_id": "abc"} + participant_data.save() + + participant_data.record_consent() + + participant_data.refresh_from_db() + assert participant_data.system_metadata["commcare_connect_channel_id"] == "abc" From 2fb95c7312f9385e5132b3e72f1924adf48d5e2c Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 13:40:30 +0200 Subject: [PATCH 02/14] Key consent enforcement on the widget release that collects it --- apps/channels/tests/test_widget_versions.py | 16 ++++++++++++++++ apps/channels/widget_versions.py | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/channels/tests/test_widget_versions.py b/apps/channels/tests/test_widget_versions.py index 5576dae8fb..8dea820b77 100644 --- a/apps/channels/tests/test_widget_versions.py +++ b/apps/channels/tests/test_widget_versions.py @@ -18,6 +18,7 @@ is_deprecated, is_outdated, latest_deprecation, + widget_enforces_consent, widget_script_url, ) from apps.utils.factories.channels import ExperimentChannelFactory @@ -142,6 +143,21 @@ def test_past_sunset_is_error(self): assert "unsupported" in status.message +@pytest.mark.parametrize( + ("version", "expected"), + [ + pytest.param(None, False, id="no-header"), + pytest.param("unknown", False, id="pre-header-widget"), + pytest.param("garbage", False, id="unparseable"), + pytest.param("0.11.0", False, id="release-a"), + pytest.param("0.12.0", True, id="release-b"), + pytest.param("1.0.0", True, id="later"), + ], +) +def test_widget_enforces_consent(version, expected): + assert widget_enforces_consent(version) is expected + + def test_widget_script_url(): assert widget_script_url() == ( f"https://unpkg.com/open-chat-studio-widget@{LATEST_VERSION}" diff --git a/apps/channels/widget_versions.py b/apps/channels/widget_versions.py index d3c9d67ead..2cd0811f3a 100644 --- a/apps/channels/widget_versions.py +++ b/apps/channels/widget_versions.py @@ -139,6 +139,17 @@ def get_widget_update_status(version: str | None) -> WidgetUpdateStatus | None: EMBED_KEY_INTRODUCED = Version("0.5.1") SESSION_TOKEN_INTRODUCED = Version("0.9.0") +# Widget release that collects consent in the composer (public channel design, D7). Consent is +# enforced on the Chat API only for widgets from this release on: older widgets treat every 403 +# as a dead session and would restart in a loop. +CONSENT_INTRODUCED = Version("0.12.0") + + +def widget_enforces_consent(version: str | None) -> bool: + """Whether a widget on `version` understands the `consent_required` refusal.""" + parsed = _parse(version) + return parsed is not None and parsed >= CONSENT_INTRODUCED + def level_for_version(version: str | None) -> int: """The highest auth level a widget on `version` can satisfy. From ecade0fce6703c59718113b6bf8c112fd8a76bb5 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 13:45:12 +0200 Subject: [PATCH 03/14] Describe a session's consent state for the Chat API --- apps/api/chat_consent.py | 45 ++++++++++++++++++ apps/api/serializers.py | 21 +++++++++ apps/api/tests/test_chat_consent_api.py | 61 +++++++++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 apps/api/chat_consent.py create mode 100644 apps/api/tests/test_chat_consent_api.py diff --git a/apps/api/chat_consent.py b/apps/api/chat_consent.py new file mode 100644 index 0000000000..04bf2d69b0 --- /dev/null +++ b/apps/api/chat_consent.py @@ -0,0 +1,45 @@ +"""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``. The text is the frozen ``ConsentForm`` on the version the session runs +against, so a republished form re-prompts through a new ``form_version_id``. +""" + +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.filter(participant=session.participant, experiment=session.experiment).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() + return { + "required": not consented, + "form_version_id": form.id, + "text": None if consented else form.get_rendered_content(), + } + + +def consent_refusal(request, session: ExperimentSession, version: Experiment) -> 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 = consent_block(version, participant_data_for(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, + ) diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 22632238e2..23151d69c1 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -349,6 +349,25 @@ 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 re-prompts.", + ) + text = serializers.CharField( + label="Consent text", + allow_null=True, + help_text="Rendered HTML of the consent form. Present only while consent is required.", + ) + + class ChatStartSessionResponse(serializers.Serializer): session_id = serializers.UUIDField(label="Session ID") session_token = serializers.CharField( @@ -360,6 +379,7 @@ class ChatStartSessionResponse(serializers.Serializer): ) chatbot = ExperimentSerializer(read_only=True) participant = ParticipantSerializer(read_only=True) + consent = ChatConsentSerializer(read_only=True) class ChatSendMessageRequest(serializers.Serializer): @@ -390,6 +410,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): diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py new file mode 100644 index 0000000000..c13a81d64c --- /dev/null +++ b/apps/api/tests/test_chat_consent_api.py @@ -0,0 +1,61 @@ +import pytest +from rest_framework.test import APIClient + +from apps.api.chat_consent import consent_block, participant_data_for +from apps.experiments.models import ParticipantData +from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory, ExperimentSessionFactory + + +@pytest.fixture() +def api_client(): + return APIClient() + + +@pytest.fixture() +def consent_experiment(team_with_users): + form = ConsentFormFactory.create(team=team_with_users, consent_text="Please **agree**") + return ExperimentFactory.create(team=team_with_users, consent_form=form) + + +@pytest.fixture() +def session(consent_experiment): + return ExperimentSessionFactory.create(experiment=consent_experiment, session_token_required=False) + + +@pytest.mark.django_db() +def test_consent_block_without_a_form_is_not_required(experiment): + experiment.consent_form = None + assert consent_block(experiment, None) == {"required": False, "form_version_id": None, "text": None} + + +@pytest.mark.django_db() +def test_consent_block_with_a_form_and_no_participant_data_is_required(consent_experiment): + block = consent_block(consent_experiment, None) + assert block == { + "required": True, + "form_version_id": consent_experiment.consent_form_id, + "text": "

Please agree

", + } + + +@pytest.mark.django_db() +def test_consent_block_after_consent_keeps_the_form_id_and_drops_the_text(session): + data = ParticipantData.objects.create( + team=session.team, participant=session.participant, experiment=session.experiment + ) + data.record_consent() + + assert consent_block(session.experiment, data) == { + "required": False, + "form_version_id": session.experiment.consent_form_id, + "text": None, + } + + +@pytest.mark.django_db() +def test_participant_data_for_returns_the_row_for_the_working_chatbot(session): + assert participant_data_for(session) is None + row = ParticipantData.objects.create( + team=session.team, participant=session.participant, experiment=session.experiment + ) + assert participant_data_for(session) == row From cd3192877b1cce96ecc3ee531e31921a13e49312 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 13:55:06 +0200 Subject: [PATCH 04/14] Report the consent state when a chat session starts --- api-schemas/v1.yml | 45 +++++++++++++++++++++++++ api-schemas/v2.yml | 2 ++ apps/api/tests/test_chat_api_anon.py | 5 +++ apps/api/tests/test_chat_consent_api.py | 41 ++++++++++++++++++++++ apps/api/views/chat.py | 4 +++ apps/experiments/models.py | 1 + 6 files changed, 98 insertions(+) diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index db9001021b..7201fcdefe 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -213,6 +213,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: @@ -226,6 +230,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': @@ -1105,6 +1113,31 @@ 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 re-prompts. + 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 ChatPollResponse: type: object properties: @@ -1120,7 +1153,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 @@ -1225,8 +1263,13 @@ components: allOf: - $ref: '#/components/schemas/Participant' readOnly: true + consent: + allOf: + - $ref: '#/components/schemas/ChatConsent' + readOnly: true required: - chatbot + - consent - participant - session_id ChatTaskPoll: @@ -2005,6 +2048,8 @@ components: participants:read: Read Participant Data participants:write: Update Participant Data usage:read: Read usage and activity data + openid: OpenID Connect scope + profile: User Profile apiKeyAuth: type: apiKey in: header diff --git a/api-schemas/v2.yml b/api-schemas/v2.yml index 76c8ac6889..fa255f53ad 100644 --- a/api-schemas/v2.yml +++ b/api-schemas/v2.yml @@ -2877,6 +2877,8 @@ components: participants:read: Read Participant Data participants:write: Update Participant Data usage:read: Read usage and activity data + openid: OpenID Connect scope + profile: User Profile apiKeyAuth: type: apiKey in: header diff --git a/apps/api/tests/test_chat_api_anon.py b/apps/api/tests/test_chat_api_anon.py index 02e53f6d5f..5c995cd05a 100644 --- a/apps/api/tests/test_chat_api_anon.py +++ b/apps/api/tests/test_chat_api_anon.py @@ -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:") diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index c13a81d64c..5060d31721 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -1,4 +1,5 @@ import pytest +from django.urls import reverse from rest_framework.test import APIClient from apps.api.chat_consent import consent_block, participant_data_for @@ -22,6 +23,11 @@ def session(consent_experiment): return ExperimentSessionFactory.create(experiment=consent_experiment, session_token_required=False) +@pytest.fixture() +def plain_experiment(team_with_users): + return ExperimentFactory.create(team=team_with_users, consent_form=None) + + @pytest.mark.django_db() def test_consent_block_without_a_form_is_not_required(experiment): experiment.consent_form = None @@ -59,3 +65,38 @@ def test_participant_data_for_returns_the_row_for_the_working_chatbot(session): team=session.team, participant=session.participant, experiment=session.experiment ) assert participant_data_for(session) == row + + +def _start(api_client, experiment, **extra): + url = reverse("api:chat:start-session") + return api_client.post(url, data={"chatbot_id": experiment.public_id}, format="json", **extra) + + +@pytest.mark.django_db() +def test_start_reports_consent_required_for_a_consent_form_chatbot(api_client, consent_experiment): + response = _start(api_client, consent_experiment) + + assert response.status_code == 201 + assert response.json()["consent"] == { + "required": True, + "form_version_id": consent_experiment.consent_form_id, + "text": "

Please agree

", + } + + +@pytest.mark.django_db() +def test_start_reports_the_published_versions_frozen_form(api_client, consent_experiment): + published = consent_experiment.create_new_version(make_default=True) + + response = _start(api_client, consent_experiment) + + frozen_form_id = response.json()["consent"]["form_version_id"] + assert frozen_form_id == published.consent_form_id + assert frozen_form_id != consent_experiment.consent_form_id + + +@pytest.mark.django_db() +def test_start_reports_no_consent_needed_without_a_form(api_client, plain_experiment): + response = _start(api_client, plain_experiment) + + assert response.json()["consent"] == {"required": False, "form_version_id": None, "text": None} diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 74f2411769..11764d27cc 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -26,6 +26,7 @@ get_embed_key_channel, oauth_resolved_channel, ) +from apps.api.chat_consent import consent_block, participant_data_for from apps.api.exceptions import ChatApiAccessDenied from apps.api.permissions import SessionAccessPermission, WidgetDomainPermission from apps.api.serializers import ( @@ -444,6 +445,7 @@ def _resolve_experiment_channel(request, team, session_data, embed_key_channel, "url": "https://example.com/api/experiments/123e4567-e89b-12d3-a456-426614174000/", }, "participant": {"identifier": "abc", "remote_id": "abc"}, + "consent": {"required": False, "form_version_id": None, "text": None}, }, response_only=True, ), @@ -460,6 +462,7 @@ def _resolve_experiment_channel(request, team, session_data, embed_key_channel, "url": "https://example.com/api/experiments/123e4567-e89b-12d3-a456-426614174000/", }, "participant": {"identifier": "abc", "remote_id": "abc"}, + "consent": {"required": False, "form_version_id": None, "text": None}, }, response_only=True, ), @@ -557,6 +560,7 @@ def chat_start_session(request): "session_token": session_token, "chatbot": experiment_version or experiment, "participant": participant, + "consent": consent_block(experiment_version or session.experiment_version, participant_data_for(session)), } serialized_response = ChatStartSessionResponse(response_data, context={"request": request}) diff --git a/apps/experiments/models.py b/apps/experiments/models.py index b83d27378b..207a79e94e 100644 --- a/apps/experiments/models.py +++ b/apps/experiments/models.py @@ -892,6 +892,7 @@ def create_new_version( # ty: ignore[invalid-method-override] if not is_copy: # nothing to do for copy - just reference the same object in the new copy self._copy_attr_to_new_version("consent_form", new_version) + new_version.save(update_fields=["consent_form"]) # Version the pipeline before the triggers so a trigger referencing this experiment's own # pipeline pins to the version just created here rather than spawning a redundant one. From 9c166cc2fe85e6a6c7245b0ea022698857c80347 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 13:58:12 +0200 Subject: [PATCH 05/14] Report the consent state on every poll --- apps/api/tests/test_chat_api_anon.py | 18 ++++++++++++++- apps/api/tests/test_chat_consent_api.py | 29 +++++++++++++++++++++++++ apps/api/views/chat.py | 7 +++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/api/tests/test_chat_api_anon.py b/apps/api/tests/test_chat_api_anon.py index 5c995cd05a..c5d4499dc0 100644 --- a/apps/api/tests/test_chat_api_anon.py +++ b/apps/api/tests/test_chat_api_anon.py @@ -152,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() @@ -193,10 +202,16 @@ 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}) @@ -204,6 +219,7 @@ def test_session_poll_with_messages(api_client, session): "has_more": True, "messages": [expected_messages[0]], "session_status": "active", + "consent": expected_consent, } diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index 5060d31721..0d0392dc1d 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -100,3 +100,32 @@ def test_start_reports_no_consent_needed_without_a_form(api_client, plain_experi response = _start(api_client, plain_experiment) assert response.json()["consent"] == {"required": False, "form_version_id": None, "text": None} + + +def _poll(api_client, session, **extra): + url = reverse("api:chat:poll-response", kwargs={"session_id": session.external_id}) + return api_client.get(url, **extra) + + +@pytest.mark.django_db() +def test_poll_reports_consent_required_before_acceptance(api_client, session): + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + assert response.status_code == 200 + assert response.json()["consent"]["required"] is True + assert response.json()["consent"]["form_version_id"] == session.experiment.consent_form_id + + +@pytest.mark.django_db() +def test_poll_reports_consent_satisfied_after_acceptance(api_client, session): + ParticipantData.objects.create( + team=session.team, participant=session.participant, experiment=session.experiment + ).record_consent() + + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + assert response.json()["consent"] == { + "required": False, + "form_version_id": session.experiment.consent_form_id, + "text": None, + } diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 11764d27cc..69983709e3 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -854,7 +854,12 @@ def chat_poll_response(request, session_id): messages = messages[:limit] session_status = "ended" if session.is_complete else "active" - response_data = {"messages": messages, "has_more": has_more, "session_status": session_status} + response_data = { + "messages": messages, + "has_more": has_more, + "session_status": session_status, + "consent": consent_block(session.experiment_version, participant_data_for(session)), + } return Response(ChatPollResponse(response_data, context={"request": request}).data, status=status.HTTP_200_OK) From 6dd19488759999edbc5f4f8823ef26220d519129 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 14:02:34 +0200 Subject: [PATCH 06/14] Regenerate the schema without the local OIDC scopes --- api-schemas/v1.yml | 2 -- api-schemas/v2.yml | 2 -- 2 files changed, 4 deletions(-) diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index 7201fcdefe..4bc0447233 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -2048,8 +2048,6 @@ components: participants:read: Read Participant Data participants:write: Update Participant Data usage:read: Read usage and activity data - openid: OpenID Connect scope - profile: User Profile apiKeyAuth: type: apiKey in: header diff --git a/api-schemas/v2.yml b/api-schemas/v2.yml index fa255f53ad..76c8ac6889 100644 --- a/api-schemas/v2.yml +++ b/api-schemas/v2.yml @@ -2877,8 +2877,6 @@ components: participants:read: Read Participant Data participants:write: Update Participant Data usage:read: Read usage and activity data - openid: OpenID Connect scope - profile: User Profile apiKeyAuth: type: apiKey in: header From c1fdb4fbd30f32a71c8576b80174706b6e531d1d Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 14:12:13 +0200 Subject: [PATCH 07/14] Record consent through the Chat API --- api-schemas/v1.yml | 61 +++++++++++++++++++++++ apps/api/serializers.py | 7 +++ apps/api/tests/test_chat_consent_api.py | 59 ++++++++++++++++++++++ apps/api/urls.py | 1 + apps/api/views/__init__.py | 10 +++- apps/api/views/chat.py | 66 +++++++++++++++++++++++++ 6 files changed, 203 insertions(+), 1 deletion(-) diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index 4bc0447233..d6e4b8abd9 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -51,6 +51,43 @@ 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 + '409': + content: + application/json: + schema: + $ref: '#/components/schemas/ChatConsentStale' + description: '' /api/chat/{session_id}/message/: post: operationId: chat_send_message @@ -1138,6 +1175,30 @@ components: - 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 + 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: diff --git a/apps/api/serializers.py b/apps/api/serializers.py index 23151d69c1..bc746ed72c 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -368,6 +368,13 @@ class ChatConsentSerializer(serializers.Serializer): ) +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( diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index 0d0392dc1d..447dbc189a 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -129,3 +129,62 @@ def test_poll_reports_consent_satisfied_after_acceptance(api_client, session): "form_version_id": session.experiment.consent_form_id, "text": None, } + + +def _consent(api_client, session, form_version_id, **extra): + url = reverse("api:chat:record-consent", kwargs={"session_id": session.external_id}) + return api_client.post(url, data={"form_version_id": form_version_id}, format="json", **extra) + + +@pytest.mark.django_db() +def test_recording_consent_marks_the_participant_and_returns_no_content(api_client, session): + response = _consent(api_client, session, session.experiment.consent_form_id) + + assert response.status_code == 204 + assert participant_data_for(session).has_consented() + + +@pytest.mark.django_db() +def test_recording_consent_twice_is_a_no_op(api_client, session): + _consent(api_client, session, session.experiment.consent_form_id) + response = _consent(api_client, session, session.experiment.consent_form_id) + + assert response.status_code == 204 + + +@pytest.mark.django_db() +def test_recording_consent_against_a_stale_form_is_refused_with_the_current_form(api_client, session): + response = _consent(api_client, session, session.experiment.consent_form_id + 1) + + assert response.status_code == 409 + body = response.json() + assert body["code"] == "consent_stale" + assert body["consent"]["required"] is True + assert body["consent"]["form_version_id"] == session.experiment.consent_form_id + assert participant_data_for(session) is None + + +@pytest.mark.django_db() +def test_recording_consent_on_a_chatbot_without_a_form_is_stale(api_client, plain_experiment): + plain_session = ExperimentSessionFactory.create(experiment=plain_experiment, session_token_required=False) + + response = _consent(api_client, plain_session, 1) + + assert response.status_code == 409 + + +@pytest.mark.django_db() +def test_recording_consent_on_an_ended_session_is_refused(api_client, session): + session.end() + + response = _consent(api_client, session, session.experiment.consent_form_id) + + assert response.status_code == 400 + + +@pytest.mark.django_db() +def test_recording_consent_does_not_touch_the_legacy_session_consent_date(api_client, session): + _consent(api_client, session, session.experiment.consent_form_id) + + session.refresh_from_db() + assert session.consent_date is None diff --git a/apps/api/urls.py b/apps/api/urls.py index 8f6d7220bf..763d5b7775 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -21,6 +21,7 @@ path("/message/", views.chat_send_message, name="send-message"), path("/poll/", views.chat_poll_response, name="poll-response"), path("//poll/", views.chat_poll_task_response, name="task-poll-response"), + path("/consent/", views.chat_record_consent, name="record-consent"), ] # The v1 API surface. v1 is frozen against today's URLs and serializers; new endpoints and the diff --git a/apps/api/views/__init__.py b/apps/api/views/__init__.py index 1aa3c5be59..12b7d336e9 100644 --- a/apps/api/views/__init__.py +++ b/apps/api/views/__init__.py @@ -1,5 +1,12 @@ from .channels import TriggerBotMessageView, callback, consent, generate_key -from .chat import chat_poll_response, chat_poll_task_response, chat_send_message, chat_start_session, chat_upload_file +from .chat import ( + chat_poll_response, + chat_poll_task_response, + chat_record_consent, + chat_send_message, + chat_start_session, + chat_upload_file, +) from .experiments import ExperimentViewSet from .files import FileContentView from .participants import ( @@ -20,5 +27,6 @@ "chat_send_message", "chat_poll_task_response", "chat_poll_response", + "chat_record_consent", "chat_upload_file", ] diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 69983709e3..12f56fd2a6 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -30,6 +30,8 @@ from apps.api.exceptions import ChatApiAccessDenied from apps.api.permissions import SessionAccessPermission, WidgetDomainPermission from apps.api.serializers import ( + ChatConsentRequest, + ChatConsentSerializer, ChatPollResponse, ChatSendMessageRequest, ChatSendMessageResponse, @@ -863,6 +865,70 @@ def chat_poll_response(request, session_id): return Response(ChatPollResponse(response_data, context={"request": request}).data, status=status.HTTP_200_OK) +@extend_schema( + operation_id="chat_record_consent", + summary="Record that the participant accepted the chatbot's consent form", + tags=["Chat"], + request=ChatConsentRequest, + responses={ + 204: None, + 409: inline_serializer( + "ChatConsentStale", + { + "error": serializers.CharField(), + "code": serializers.CharField(help_text="Always `consent_stale`."), + "consent": ChatConsentSerializer(), + }, + ), + }, + parameters=[ + OpenApiParameter( + name="session_id", + type=OpenApiTypes.STR, + location=OpenApiParameter.PATH, + description="Session ID", + ), + ], +) +@widget_sunset_headers +@api_view(["POST"]) +@throttle_classes([ChatAPIRateThrottle]) +@authentication_classes(AUTH_CLASSES) +@permission_classes(SESSION_PERMISSION_CLASSES) +def chat_record_consent(request, session_id): + """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. + """ + serializer = ChatConsentRequest(data=request.data) + serializer.is_valid(raise_exception=True) + + session = get_experiment_session_cached(session_id) + if not session: + return NotFound() + if session.ended_at is not None: + return Response({"error": "Session has ended"}, status=status.HTTP_400_BAD_REQUEST) + + version = session.experiment_version + if version.consent_form_id != serializer.validated_data["form_version_id"]: + return Response( + { + "error": "The consent form has changed", + "code": "consent_stale", + "consent": consent_block(version, participant_data_for(session)), + }, + status=status.HTTP_409_CONFLICT, + ) + + participant_data, _ = ParticipantData.objects.get_or_create( + participant=session.participant, experiment=session.experiment, team=session.team, defaults={"data": {}} + ) + participant_data.record_consent() + return Response(status=status.HTTP_204_NO_CONTENT) + + def get_progress_message(session_id, chatbot_name, chatbot_description, throttle_key=None) -> str | None: """Get the next progress message. This will generate new messages if there are no more messages. From cacce4f219bb5f2ede85ffdb22fb69d1b1e66b15 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 14:13:48 +0200 Subject: [PATCH 08/14] Refuse consent on a completed session like the other chat endpoints --- apps/api/tests/test_chat_consent_api.py | 4 ++-- apps/api/views/chat.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index 447dbc189a..53247b971f 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -3,7 +3,7 @@ from rest_framework.test import APIClient from apps.api.chat_consent import consent_block, participant_data_for -from apps.experiments.models import ParticipantData +from apps.experiments.models import ParticipantData, SessionStatus from apps.utils.factories.experiment import ConsentFormFactory, ExperimentFactory, ExperimentSessionFactory @@ -175,7 +175,7 @@ def test_recording_consent_on_a_chatbot_without_a_form_is_stale(api_client, plai @pytest.mark.django_db() def test_recording_consent_on_an_ended_session_is_refused(api_client, session): - session.end() + session.update_status(SessionStatus.COMPLETE) response = _consent(api_client, session, session.experiment.consent_form_id) diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 12f56fd2a6..6f9c306bd6 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -908,7 +908,7 @@ def chat_record_consent(request, session_id): session = get_experiment_session_cached(session_id) if not session: return NotFound() - if session.ended_at is not None: + if session.is_complete: return Response({"error": "Session has ended"}, status=status.HTTP_400_BAD_REQUEST) version = session.experiment_version From a97f4cd012ddb1a45a544b4f2e7e803009af1c56 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 17:52:13 +0200 Subject: [PATCH 09/14] Hold messages and uploads until consent is recorded --- api-schemas/v1.yml | 26 +++++++++++ apps/api/tests/test_chat_consent_api.py | 58 +++++++++++++++++++++++++ apps/api/views/chat.py | 21 +++++++-- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index d6e4b8abd9..c5408ae9b7 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -130,6 +130,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 @@ -195,6 +201,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 @@ -1185,6 +1197,20 @@ components: 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 ChatConsentStale: type: object properties: diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index 53247b971f..ebfbb387aa 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -1,4 +1,7 @@ +from unittest import mock + import pytest +from django.core.files.uploadedfile import SimpleUploadedFile from django.urls import reverse from rest_framework.test import APIClient @@ -188,3 +191,58 @@ def test_recording_consent_does_not_touch_the_legacy_session_consent_date(api_cl session.refresh_from_db() assert session.consent_date is None + + +def _send(api_client, session, **extra): + url = reverse("api:chat:send-message", kwargs={"session_id": session.external_id}) + return api_client.post(url, data={"message": "hi"}, format="json", **extra) + + +def _upload(api_client, session, **extra): + url = reverse("api:chat:upload-file", kwargs={"session_id": session.external_id}) + upload = SimpleUploadedFile("note.txt", b"hello", content_type="text/plain") + return api_client.post(url, data={"files": [upload]}, format="multipart", **extra) + + +@pytest.mark.django_db() +@pytest.mark.parametrize("call", [pytest.param(_send, id="send"), pytest.param(_upload, id="upload")]) +def test_release_b_widget_is_refused_until_consent_is_recorded(api_client, session, call): + response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + assert response.status_code == 403 + body = response.json() + assert body["code"] == "consent_required" + assert body["consent"]["form_version_id"] == session.experiment.consent_form_id + assert body["consent"]["text"] + + +@pytest.mark.django_db() +@pytest.mark.parametrize("call", [pytest.param(_send, id="send"), pytest.param(_upload, id="upload")]) +def test_release_b_widget_passes_once_consent_is_recorded(api_client, session, call): + _consent(api_client, session, session.experiment.consent_form_id, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + with mock.patch("apps.api.views.chat.get_response_for_webchat_task"): + response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + assert response.status_code in (201, 202) + + +@pytest.mark.django_db() +@pytest.mark.parametrize( + "widget_version", + [pytest.param("0.11.0", id="release-a"), pytest.param(None, id="no-header")], +) +def test_older_widgets_and_api_callers_are_not_gated(api_client, session, widget_version): + extra = {"HTTP_X_OCS_WIDGET_VERSION": widget_version} if widget_version else {} + + with mock.patch("apps.api.views.chat.get_response_for_webchat_task"): + response = _send(api_client, session, **extra) + + assert response.status_code == 202 + + +@pytest.mark.django_db() +def test_poll_is_never_gated(api_client, session): + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + assert response.status_code == 200 diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 6f9c306bd6..ebf413da5e 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -26,7 +26,7 @@ get_embed_key_channel, oauth_resolved_channel, ) -from apps.api.chat_consent import consent_block, participant_data_for +from apps.api.chat_consent import consent_block, consent_refusal, participant_data_for from apps.api.exceptions import ChatApiAccessDenied from apps.api.permissions import SessionAccessPermission, WidgetDomainPermission from apps.api.serializers import ( @@ -77,6 +77,15 @@ MAX_TOTAL_SIZE_MB = 50 SUPPORTED_FILE_EXTENSIONS = settings.SUPPORTED_FILE_TYPES["chat_attachments"].split(",") +CONSENT_REQUIRED_RESPONSE = inline_serializer( + "ChatConsentRequired", + { + "error": serializers.CharField(), + "code": serializers.CharField(help_text="`consent_required`."), + "consent": ChatConsentSerializer(), + }, +) + logger = logging.getLogger("ocs.api_chat") @@ -142,7 +151,8 @@ def validate_file_upload(file): many=True, ) }, - ) + ), + 403: CONSENT_REQUIRED_RESPONSE, }, parameters=[ OpenApiParameter( @@ -165,6 +175,8 @@ def chat_upload_file(request, session_id): if session.is_complete: return Response({"error": "Session has ended"}, status=status.HTTP_400_BAD_REQUEST) + if refusal := consent_refusal(request, session, session.experiment_version): + return refusal files = request.FILES.getlist("files") if not files: return Response({"error": "No files provided"}, status=status.HTTP_400_BAD_REQUEST) @@ -586,7 +598,7 @@ class ChatSendMessageRequestWithAttachments(ChatSendMessageRequest): summary="Send a message to a chat session", tags=["Chat"], request=ChatSendMessageRequestWithAttachments, - responses={202: ChatSendMessageResponse}, + responses={202: ChatSendMessageResponse, 403: CONSENT_REQUIRED_RESPONSE}, parameters=[ OpenApiParameter( name="session_id", @@ -655,6 +667,9 @@ def chat_send_message(request, session_id): else: experiment_version = session.experiment_version + if refusal := consent_refusal(request, session, experiment_version): + return refusal + attachment_data = [] if attachment_ids: # Only files uploaded for *this* session may be attached. Scoping on the team alone From 8443ea1f75bf92ce694d70a7fe1d9af2e7a0cc39 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 18:14:58 +0200 Subject: [PATCH 10/14] Skip the participant data query when a version has no consent form Also documents the 400 response chat_record_consent already returns for an ended session, and regenerates the schema for it. --- api-schemas/v1.yml | 13 +++++++++++++ apps/api/chat_consent.py | 9 ++++++++- apps/api/views/chat.py | 12 ++++++++---- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index c5408ae9b7..5649cdc928 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -82,6 +82,12 @@ paths: responses: '204': description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ChatConsentSessionEnded' + description: '' '409': content: application/json: @@ -1211,6 +1217,13 @@ components: - code - consent - error + ChatConsentSessionEnded: + type: object + properties: + error: + type: string + required: + - error ChatConsentStale: type: object properties: diff --git a/apps/api/chat_consent.py b/apps/api/chat_consent.py index 04bf2d69b0..f75c6e2b27 100644 --- a/apps/api/chat_consent.py +++ b/apps/api/chat_consent.py @@ -28,6 +28,13 @@ def consent_block(version: Experiment, participant_data: ParticipantData | None) } +def session_consent_block(session: ExperimentSession, version: Experiment) -> dict: + """The consent block for `session` on `version`, without a participant-data query when there is no form.""" + 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, version: Experiment) -> Response | None: """The 403 that holds a message until consent is recorded, or None. @@ -36,7 +43,7 @@ def consent_refusal(request, session: ExperimentSession, version: Experiment) -> """ if not widget_enforces_consent(request.headers.get(WIDGET_VERSION_HEADER)): return None - block = consent_block(version, participant_data_for(session)) + block = session_consent_block(session, version) if not block["required"]: return None return Response( diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index ebf413da5e..57e010cd7a 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -26,7 +26,7 @@ get_embed_key_channel, oauth_resolved_channel, ) -from apps.api.chat_consent import consent_block, consent_refusal, participant_data_for +from apps.api.chat_consent import consent_refusal, session_consent_block from apps.api.exceptions import ChatApiAccessDenied from apps.api.permissions import SessionAccessPermission, WidgetDomainPermission from apps.api.serializers import ( @@ -574,7 +574,7 @@ def chat_start_session(request): "session_token": session_token, "chatbot": experiment_version or experiment, "participant": participant, - "consent": consent_block(experiment_version or session.experiment_version, participant_data_for(session)), + "consent": session_consent_block(session, experiment_version or session.experiment_version), } serialized_response = ChatStartSessionResponse(response_data, context={"request": request}) @@ -875,7 +875,7 @@ def chat_poll_response(request, session_id): "messages": messages, "has_more": has_more, "session_status": session_status, - "consent": consent_block(session.experiment_version, participant_data_for(session)), + "consent": session_consent_block(session, session.experiment_version), } return Response(ChatPollResponse(response_data, context={"request": request}).data, status=status.HTTP_200_OK) @@ -887,6 +887,10 @@ def chat_poll_response(request, session_id): request=ChatConsentRequest, responses={ 204: None, + 400: inline_serializer( + "ChatConsentSessionEnded", + {"error": serializers.CharField()}, + ), 409: inline_serializer( "ChatConsentStale", { @@ -932,7 +936,7 @@ def chat_record_consent(request, session_id): { "error": "The consent form has changed", "code": "consent_stale", - "consent": consent_block(version, participant_data_for(session)), + "consent": session_consent_block(session, version), }, status=status.HTTP_409_CONFLICT, ) From 615306a9cfce01bb4668d7717021056d8bb797fa Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 18:15:05 +0200 Subject: [PATCH 11/14] Cover the frozen consent form and the consent endpoint's access control Adds a regression test for create_new_version persisting the frozen consent form, a prerelease case for widget_enforces_consent, a test that a missing session token refuses consent recording without creating ParticipantData, exact per-endpoint status codes for the release B pass-through test, and coverage that polling skips the ParticipantData query on a no-form version. --- apps/api/tests/test_chat_consent_api.py | 45 +++++++++++++++++++-- apps/channels/tests/test_widget_versions.py | 1 + apps/experiments/tests/test_models.py | 9 +++++ 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index ebfbb387aa..8c3f1e8ee2 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -110,6 +110,26 @@ def _poll(api_client, session, **extra): return api_client.get(url, **extra) +@pytest.mark.django_db() +def test_poll_skips_the_participant_data_query_when_the_version_has_no_form(api_client, plain_experiment): + plain_session = ExperimentSessionFactory.create(experiment=plain_experiment, session_token_required=False) + + with mock.patch("apps.api.chat_consent.participant_data_for") as mocked: + response = _poll(api_client, plain_session) + + assert response.status_code == 200 + mocked.assert_not_called() + + +@pytest.mark.django_db() +def test_poll_queries_participant_data_when_the_version_has_a_form(api_client, session): + with mock.patch("apps.api.chat_consent.participant_data_for", wraps=participant_data_for) as mocked: + response = _poll(api_client, session) + + assert response.status_code == 200 + mocked.assert_called_once_with(session) + + @pytest.mark.django_db() def test_poll_reports_consent_required_before_acceptance(api_client, session): response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") @@ -185,6 +205,17 @@ def test_recording_consent_on_an_ended_session_is_refused(api_client, session): assert response.status_code == 400 +@pytest.mark.django_db() +def test_recording_consent_without_a_session_token_is_refused(api_client, consent_experiment): + token_required_session = ExperimentSessionFactory.create(experiment=consent_experiment) + + response = _consent(api_client, token_required_session, consent_experiment.consent_form_id) + + assert response.status_code == 403 + assert response.json()["code"] == "session_token_required" + assert participant_data_for(token_required_session) is None + + @pytest.mark.django_db() def test_recording_consent_does_not_touch_the_legacy_session_consent_date(api_client, session): _consent(api_client, session, session.experiment.consent_form_id) @@ -217,14 +248,20 @@ def test_release_b_widget_is_refused_until_consent_is_recorded(api_client, sessi @pytest.mark.django_db() -@pytest.mark.parametrize("call", [pytest.param(_send, id="send"), pytest.param(_upload, id="upload")]) -def test_release_b_widget_passes_once_consent_is_recorded(api_client, session, call): +@pytest.mark.parametrize( + ("call", "expected_status"), + [pytest.param(_send, 202, id="send"), pytest.param(_upload, 201, id="upload")], +) +def test_release_b_widget_passes_once_consent_is_recorded(api_client, session, call, expected_status): _consent(api_client, session, session.experiment.consent_form_id, HTTP_X_OCS_WIDGET_VERSION="0.12.0") - with mock.patch("apps.api.views.chat.get_response_for_webchat_task"): + if call is _send: + with mock.patch("apps.api.views.chat.get_response_for_webchat_task"): + response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + else: response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") - assert response.status_code in (201, 202) + assert response.status_code == expected_status @pytest.mark.django_db() diff --git a/apps/channels/tests/test_widget_versions.py b/apps/channels/tests/test_widget_versions.py index 8dea820b77..d3728fb608 100644 --- a/apps/channels/tests/test_widget_versions.py +++ b/apps/channels/tests/test_widget_versions.py @@ -152,6 +152,7 @@ def test_past_sunset_is_error(self): pytest.param("0.11.0", False, id="release-a"), pytest.param("0.12.0", True, id="release-b"), pytest.param("1.0.0", True, id="later"), + pytest.param("0.12.0rc1", False, id="release-b-prerelease"), ], ) def test_widget_enforces_consent(version, expected): diff --git a/apps/experiments/tests/test_models.py b/apps/experiments/tests/test_models.py index 5e0fd5bf53..1e04f52dc5 100644 --- a/apps/experiments/tests/test_models.py +++ b/apps/experiments/tests/test_models.py @@ -784,6 +784,15 @@ def test_create_experiment_version(self): assert another_new_version.version_number == 2 assert another_new_version.is_default_version is False + def test_create_new_version_persists_the_frozen_consent_form(self): + experiment = self._setup_original_experiment() + + version = experiment.create_new_version(make_default=True) + + persisted_version = Experiment.objects.get(id=version.id) + assert persisted_version.consent_form_id != experiment.consent_form_id + assert persisted_version.consent_form.working_version_id == experiment.consent_form_id + def _assert_pipeline_is_duplicated(self, original_experiment, new_version): assert new_version.pipeline.working_version == original_experiment.pipeline assert new_version.pipeline.version_number == 1 From a503dd63f2a7c5881a66945f58a351350b8eb725 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 19:20:20 +0200 Subject: [PATCH 12/14] Key consent on the accepted form version The Chat API store held only a consent boolean and a timestamp, so a participant who accepted an earlier form was treated as consented to a republished one, and consent recorded through CommCare Connect or the legacy page (no form id) satisfied the widget gate. record_consent now stores consent_form_version_id and the gate checks has_consented_to(), so a changed form prompts restored sessions and returning participants again, as the module docstring already claimed. Look up ParticipantData through the for_experiment manager and key the get_or_create on the working version, raise NotFound rather than returning it, and regenerate the schema for the reworded help text. Claude-Session: https://claude.ai/code/session_01BGnK6pKcwCMD4MG2b8WHEh --- api-schemas/v1.yml | 5 +- apps/api/chat_consent.py | 9 +-- apps/api/serializers.py | 4 +- apps/api/tests/test_chat_consent_api.py | 63 ++++++++++++++++++- apps/api/views/chat.py | 12 ++-- apps/experiments/models.py | 13 +++- apps/experiments/tests/test_models.py | 8 +++ .../tests/test_participant_consent.py | 36 +++++++++-- 8 files changed, 127 insertions(+), 23 deletions(-) diff --git a/api-schemas/v1.yml b/api-schemas/v1.yml index 5649cdc928..7ef6d6ad2d 100644 --- a/api-schemas/v1.yml +++ b/api-schemas/v1.yml @@ -1181,8 +1181,9 @@ components: 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 re-prompts. + 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 diff --git a/apps/api/chat_consent.py b/apps/api/chat_consent.py index f75c6e2b27..ec2c6627da 100644 --- a/apps/api/chat_consent.py +++ b/apps/api/chat_consent.py @@ -1,8 +1,9 @@ """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``. The text is the frozen ``ConsentForm`` on the version the session runs -against, so a republished form re-prompts through a new ``form_version_id``. +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 @@ -13,14 +14,14 @@ def participant_data_for(session: ExperimentSession) -> ParticipantData | None: - return ParticipantData.objects.filter(participant=session.participant, experiment=session.experiment).first() + 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() + consented = participant_data is not None and participant_data.has_consented_to(form.id) return { "required": not consented, "form_version_id": form.id, diff --git a/apps/api/serializers.py b/apps/api/serializers.py index bc746ed72c..ebdd9ce578 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -358,8 +358,8 @@ class ChatConsentSerializer(serializers.Serializer): 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 re-prompts.", + 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", diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index 8c3f1e8ee2..a9f4f9f6ca 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -52,7 +52,7 @@ def test_consent_block_after_consent_keeps_the_form_id_and_drops_the_text(sessio data = ParticipantData.objects.create( team=session.team, participant=session.participant, experiment=session.experiment ) - data.record_consent() + data.record_consent(session.experiment.consent_form_id) assert consent_block(session.experiment, data) == { "required": False, @@ -61,6 +61,33 @@ def test_consent_block_after_consent_keeps_the_form_id_and_drops_the_text(sessio } +@pytest.mark.django_db() +def test_consent_block_is_required_again_once_the_form_is_republished(session): + data = ParticipantData.objects.create( + team=session.team, participant=session.participant, experiment=session.experiment + ) + data.record_consent(session.experiment.consent_form_id) + session.experiment.consent_form.consent_text = "Please agree to the **new** terms" + session.experiment.consent_form.save() + republished = session.experiment.create_new_version(make_default=True) + + assert consent_block(republished, data) == { + "required": True, + "form_version_id": republished.consent_form_id, + "text": "

Please agree to the new terms

", + } + + +@pytest.mark.django_db() +def test_consent_block_does_not_treat_consent_from_another_channel_as_accepting_the_form(session): + data = ParticipantData.objects.create( + team=session.team, participant=session.participant, experiment=session.experiment + ) + data.update_consent(True) + + assert consent_block(session.experiment, data)["required"] is True + + @pytest.mark.django_db() def test_participant_data_for_returns_the_row_for_the_working_chatbot(session): assert participant_data_for(session) is None @@ -143,7 +170,7 @@ def test_poll_reports_consent_required_before_acceptance(api_client, session): def test_poll_reports_consent_satisfied_after_acceptance(api_client, session): ParticipantData.objects.create( team=session.team, participant=session.participant, experiment=session.experiment - ).record_consent() + ).record_consent(session.experiment.consent_form_id) response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") @@ -164,7 +191,35 @@ def test_recording_consent_marks_the_participant_and_returns_no_content(api_clie response = _consent(api_client, session, session.experiment.consent_form_id) assert response.status_code == 204 - assert participant_data_for(session).has_consented() + assert participant_data_for(session).has_consented_to(session.experiment.consent_form_id) + + +@pytest.mark.django_db() +def test_consent_carries_over_to_the_participants_later_sessions(api_client, session): + _consent(api_client, session, session.experiment.consent_form_id) + later_session = ExperimentSessionFactory.create( + experiment=session.experiment, participant=session.participant, session_token_required=False + ) + + response = _poll(api_client, later_session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + assert response.json()["consent"]["required"] is False + + +@pytest.mark.django_db() +def test_a_republished_form_prompts_the_participant_again(api_client, session): + _consent(api_client, session, session.experiment.consent_form_id) + session.experiment.consent_form.consent_text = "New terms" + session.experiment.consent_form.save() + republished = session.experiment.create_new_version(make_default=True) + + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + + assert response.json()["consent"] == { + "required": True, + "form_version_id": republished.consent_form_id, + "text": "

New terms

", + } @pytest.mark.django_db() @@ -280,6 +335,8 @@ def test_older_widgets_and_api_callers_are_not_gated(api_client, session, widget @pytest.mark.django_db() def test_poll_is_never_gated(api_client, session): + assert _send(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0").status_code == 403 + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") assert response.status_code == 200 diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 57e010cd7a..73006da976 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -923,15 +923,16 @@ def chat_record_consent(request, session_id): """ serializer = ChatConsentRequest(data=request.data) serializer.is_valid(raise_exception=True) + form_version_id = serializer.validated_data["form_version_id"] session = get_experiment_session_cached(session_id) if not session: - return NotFound() + raise NotFound() if session.is_complete: return Response({"error": "Session has ended"}, status=status.HTTP_400_BAD_REQUEST) version = session.experiment_version - if version.consent_form_id != serializer.validated_data["form_version_id"]: + if version.consent_form_id != form_version_id: return Response( { "error": "The consent form has changed", @@ -942,9 +943,12 @@ def chat_record_consent(request, session_id): ) participant_data, _ = ParticipantData.objects.get_or_create( - participant=session.participant, experiment=session.experiment, team=session.team, defaults={"data": {}} + participant=session.participant, + experiment=session.experiment.get_working_version(), + team=session.team, + defaults={"data": {}}, ) - participant_data.record_consent() + participant_data.record_consent(form_version_id) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/experiments/models.py b/apps/experiments/models.py index 207a79e94e..a1b0f0fb05 100644 --- a/apps/experiments/models.py +++ b/apps/experiments/models.py @@ -1338,12 +1338,21 @@ def update_consent(self, consent: bool): self.system_metadata["consent"] = consent self.save(update_fields=["system_metadata"]) - def record_consent(self) -> None: - """Record that the participant accepted the chatbot's consent form (D7 in the public channel design).""" + def has_consented_to(self, form_version_id: int) -> bool: + """Whether the participant accepted this frozen consent form. + + Consent recorded without a form id (CommCare Connect, the legacy consent page) does not + cover any form: the participant has not seen this text. + """ + return self.has_consented() and self.system_metadata.get("consent_form_version_id") == form_version_id + + def record_consent(self, form_version_id: int) -> None: + """Record that the participant accepted a frozen consent form (D7 in the public channel design).""" self.system_metadata = { **self.system_metadata, "consent": True, "consent_at": timezone.now().isoformat(), + "consent_form_version_id": form_version_id, } self.save(update_fields=["system_metadata"]) diff --git a/apps/experiments/tests/test_models.py b/apps/experiments/tests/test_models.py index 1e04f52dc5..a28cbd7841 100644 --- a/apps/experiments/tests/test_models.py +++ b/apps/experiments/tests/test_models.py @@ -793,6 +793,14 @@ def test_create_new_version_persists_the_frozen_consent_form(self): assert persisted_version.consent_form_id != experiment.consent_form_id assert persisted_version.consent_form.working_version_id == experiment.consent_form_id + def test_publishing_a_consent_form_chatbot_leaves_no_unreleased_changes(self): + experiment = self._setup_original_experiment() + + experiment.create_new_version(make_default=True) + + experiment.refresh_from_db() + assert experiment.compare_with_latest() is False + def _assert_pipeline_is_duplicated(self, original_experiment, new_version): assert new_version.pipeline.working_version == original_experiment.pipeline assert new_version.pipeline.version_number == 1 diff --git a/apps/experiments/tests/test_participant_consent.py b/apps/experiments/tests/test_participant_consent.py index 1e30219625..72f4284449 100644 --- a/apps/experiments/tests/test_participant_consent.py +++ b/apps/experiments/tests/test_participant_consent.py @@ -12,21 +12,45 @@ def participant_data(team_with_users): return ParticipantData.objects.create(team=team_with_users, participant=participant, experiment=experiment) +@pytest.fixture() +def form_id(participant_data): + return participant_data.experiment.consent_form_id + + @pytest.mark.django_db() -def test_record_consent_marks_the_participant_as_consented(participant_data): +def test_record_consent_marks_the_participant_as_consented(participant_data, form_id): assert not participant_data.has_consented() - participant_data.record_consent() + participant_data.record_consent(form_id) + + participant_data.refresh_from_db() + assert participant_data.has_consented() + + +@pytest.mark.django_db() +def test_record_consent_remembers_which_form_was_accepted(participant_data, form_id): + assert not participant_data.has_consented_to(form_id) + + participant_data.record_consent(form_id) participant_data.refresh_from_db() + assert participant_data.has_consented_to(form_id) + assert not participant_data.has_consented_to(form_id + 1) + + +@pytest.mark.django_db() +def test_consent_recorded_without_a_form_does_not_cover_any_form(participant_data, form_id): + participant_data.update_consent(True) + assert participant_data.has_consented() + assert not participant_data.has_consented_to(form_id) @pytest.mark.django_db() -def test_record_consent_stamps_the_time_of_acceptance(participant_data): +def test_record_consent_stamps_the_time_of_acceptance(participant_data, form_id): before = timezone.now() - participant_data.record_consent() + participant_data.record_consent(form_id) participant_data.refresh_from_db() consent_at = timezone.datetime.fromisoformat(participant_data.system_metadata["consent_at"]) @@ -34,11 +58,11 @@ def test_record_consent_stamps_the_time_of_acceptance(participant_data): @pytest.mark.django_db() -def test_record_consent_keeps_other_system_metadata(participant_data): +def test_record_consent_keeps_other_system_metadata(participant_data, form_id): participant_data.system_metadata = {"commcare_connect_channel_id": "abc"} participant_data.save() - participant_data.record_consent() + participant_data.record_consent(form_id) participant_data.refresh_from_db() assert participant_data.system_metadata["commcare_connect_channel_id"] == "abc" From b66ff35b75fc2ddbf5dba9292a6ef279da5bafb0 Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 19:20:21 +0200 Subject: [PATCH 13/14] Gate consent enforcement on widget 0.13.0 Widget 0.12.0 was published without the consent panel, so a gate at 0.12.0 would send every OCS-hosted widget on a consent-form chatbot a 403 it cannot handle and into a restart loop. The first release that carries the panel is now 0.13.0; 0.12.0 is explicitly below the gate. Claude-Session: https://claude.ai/code/session_01BGnK6pKcwCMD4MG2b8WHEh --- apps/api/tests/test_chat_consent_api.py | 26 ++++++++++++--------- apps/channels/tests/test_widget_versions.py | 5 ++-- apps/channels/widget_versions.py | 6 ++--- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index a9f4f9f6ca..7301b4271a 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -159,7 +159,7 @@ def test_poll_queries_participant_data_when_the_version_has_a_form(api_client, s @pytest.mark.django_db() def test_poll_reports_consent_required_before_acceptance(api_client, session): - response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") assert response.status_code == 200 assert response.json()["consent"]["required"] is True @@ -172,7 +172,7 @@ def test_poll_reports_consent_satisfied_after_acceptance(api_client, session): team=session.team, participant=session.participant, experiment=session.experiment ).record_consent(session.experiment.consent_form_id) - response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") assert response.json()["consent"] == { "required": False, @@ -201,7 +201,7 @@ def test_consent_carries_over_to_the_participants_later_sessions(api_client, ses experiment=session.experiment, participant=session.participant, session_token_required=False ) - response = _poll(api_client, later_session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = _poll(api_client, later_session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") assert response.json()["consent"]["required"] is False @@ -213,7 +213,7 @@ def test_a_republished_form_prompts_the_participant_again(api_client, session): session.experiment.consent_form.save() republished = session.experiment.create_new_version(make_default=True) - response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") assert response.json()["consent"] == { "required": True, @@ -293,7 +293,7 @@ def _upload(api_client, session, **extra): @pytest.mark.django_db() @pytest.mark.parametrize("call", [pytest.param(_send, id="send"), pytest.param(_upload, id="upload")]) def test_release_b_widget_is_refused_until_consent_is_recorded(api_client, session, call): - response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") assert response.status_code == 403 body = response.json() @@ -308,13 +308,13 @@ def test_release_b_widget_is_refused_until_consent_is_recorded(api_client, sessi [pytest.param(_send, 202, id="send"), pytest.param(_upload, 201, id="upload")], ) def test_release_b_widget_passes_once_consent_is_recorded(api_client, session, call, expected_status): - _consent(api_client, session, session.experiment.consent_form_id, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + _consent(api_client, session, session.experiment.consent_form_id, HTTP_X_OCS_WIDGET_VERSION="0.13.0") if call is _send: with mock.patch("apps.api.views.chat.get_response_for_webchat_task"): - response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") else: - response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = call(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") assert response.status_code == expected_status @@ -322,7 +322,11 @@ def test_release_b_widget_passes_once_consent_is_recorded(api_client, session, c @pytest.mark.django_db() @pytest.mark.parametrize( "widget_version", - [pytest.param("0.11.0", id="release-a"), pytest.param(None, id="no-header")], + [ + pytest.param("0.11.0", id="release-a"), + pytest.param("0.12.0", id="published-without-consent-panel"), + pytest.param(None, id="no-header"), + ], ) def test_older_widgets_and_api_callers_are_not_gated(api_client, session, widget_version): extra = {"HTTP_X_OCS_WIDGET_VERSION": widget_version} if widget_version else {} @@ -335,8 +339,8 @@ def test_older_widgets_and_api_callers_are_not_gated(api_client, session, widget @pytest.mark.django_db() def test_poll_is_never_gated(api_client, session): - assert _send(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0").status_code == 403 + assert _send(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0").status_code == 403 - response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.12.0") + response = _poll(api_client, session, HTTP_X_OCS_WIDGET_VERSION="0.13.0") assert response.status_code == 200 diff --git a/apps/channels/tests/test_widget_versions.py b/apps/channels/tests/test_widget_versions.py index d3728fb608..7bad78d1b3 100644 --- a/apps/channels/tests/test_widget_versions.py +++ b/apps/channels/tests/test_widget_versions.py @@ -150,9 +150,10 @@ def test_past_sunset_is_error(self): pytest.param("unknown", False, id="pre-header-widget"), pytest.param("garbage", False, id="unparseable"), pytest.param("0.11.0", False, id="release-a"), - pytest.param("0.12.0", True, id="release-b"), + pytest.param("0.12.0", False, id="published-without-consent-panel"), + pytest.param("0.13.0", True, id="release-b"), pytest.param("1.0.0", True, id="later"), - pytest.param("0.12.0rc1", False, id="release-b-prerelease"), + pytest.param("0.13.0rc1", False, id="release-b-prerelease"), ], ) def test_widget_enforces_consent(version, expected): diff --git a/apps/channels/widget_versions.py b/apps/channels/widget_versions.py index 2cd0811f3a..b121de113c 100644 --- a/apps/channels/widget_versions.py +++ b/apps/channels/widget_versions.py @@ -140,9 +140,9 @@ def get_widget_update_status(version: str | None) -> WidgetUpdateStatus | None: SESSION_TOKEN_INTRODUCED = Version("0.9.0") # Widget release that collects consent in the composer (public channel design, D7). Consent is -# enforced on the Chat API only for widgets from this release on: older widgets treat every 403 -# as a dead session and would restart in a loop. -CONSENT_INTRODUCED = Version("0.12.0") +# enforced on the Chat API only for widgets from this release on: older widgets, 0.12.0 included, +# treat every 403 as a dead session and would restart in a loop. +CONSENT_INTRODUCED = Version("0.13.0") def widget_enforces_consent(version: str | None) -> bool: From 86bf68af74b569ce939d22bb5046eb4a6807071c Mon Sep 17 00:00:00 2001 From: barry47products Date: Thu, 27 Aug 2026 19:43:41 +0200 Subject: [PATCH 14/14] Keep the first acceptance time on a repeated consent POST record_consent rewrote consent_at on every call, so a widget retry or panel re-render replaced the time the participant actually accepted the form. The view now skips the write when consent for that form is already recorded. Claude-Session: https://claude.ai/code/session_01BGnK6pKcwCMD4MG2b8WHEh --- apps/api/tests/test_chat_consent_api.py | 6 +++++- apps/api/views/chat.py | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/tests/test_chat_consent_api.py b/apps/api/tests/test_chat_consent_api.py index 7301b4271a..e089807096 100644 --- a/apps/api/tests/test_chat_consent_api.py +++ b/apps/api/tests/test_chat_consent_api.py @@ -223,11 +223,15 @@ def test_a_republished_form_prompts_the_participant_again(api_client, session): @pytest.mark.django_db() -def test_recording_consent_twice_is_a_no_op(api_client, session): +def test_recording_consent_twice_keeps_the_first_acceptance_time(api_client, session): _consent(api_client, session, session.experiment.consent_form_id) + first_consent_at = participant_data_for(session).system_metadata["consent_at"] + response = _consent(api_client, session, session.experiment.consent_form_id) assert response.status_code == 204 + assert participant_data_for(session).system_metadata["consent_at"] == first_consent_at + assert ParticipantData.objects.filter(participant=session.participant).count() == 1 @pytest.mark.django_db() diff --git a/apps/api/views/chat.py b/apps/api/views/chat.py index 73006da976..621fdb6a57 100644 --- a/apps/api/views/chat.py +++ b/apps/api/views/chat.py @@ -948,7 +948,8 @@ def chat_record_consent(request, session_id): team=session.team, defaults={"data": {}}, ) - participant_data.record_consent(form_version_id) + if not participant_data.has_consented_to(form_version_id): + participant_data.record_consent(form_version_id) return Response(status=status.HTTP_204_NO_CONTENT)