From f9bce86122ef90178657468bf4787254621577b4 Mon Sep 17 00:00:00 2001 From: dansubak Date: Wed, 16 Sep 2026 11:37:24 -0400 Subject: [PATCH 1/2] Initial implementation of simple data consent api. --- b2b/serializers/v0/__init__.py | 19 ++++++++++++ b2b/views/v0/__init__.py | 35 ++++++++++++++++++++++ b2b/views/v0/urls.py | 7 ++++- openapi/specs/v0.yaml | 53 ++++++++++++++++++++++++++++++++++ openapi/specs/v1.yaml | 53 ++++++++++++++++++++++++++++++++++ openapi/specs/v2.yaml | 53 ++++++++++++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 1 deletion(-) diff --git a/b2b/serializers/v0/__init__.py b/b2b/serializers/v0/__init__.py index 91772062af..a839d748b8 100644 --- a/b2b/serializers/v0/__init__.py +++ b/b2b/serializers/v0/__init__.py @@ -169,3 +169,22 @@ class CreateB2BEnrollmentSerializer(serializers.Serializer): max_digits=None, decimal_places=2, read_only=True, required=False ) checkout_result = GenerateCheckoutPayloadSerializer(required=False) + + +class DataConsentSerializer(serializers.Serializer): + """ + Records whether a user has consented to data sharing for a contract + """ + + consented = serializers.BooleanField(allow_null=False, required=True) + + +# This kinda sucks, is there really no standard way to annotate the default behavior for a DRF validity exception? +class DataConsentValidationErrorSerializer(serializers.Serializer): + """Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer.""" + + consented = serializers.ListField( + child=serializers.CharField(), + required=False, + help_text="Errors for the 'consented' field, e.g. if missing or not a boolean.", + ) diff --git a/b2b/views/v0/__init__.py b/b2b/views/v0/__init__.py index 640ca29734..b40d467b91 100644 --- a/b2b/views/v0/__init__.py +++ b/b2b/views/v0/__init__.py @@ -30,6 +30,8 @@ B2BEnrollRequestSerializer, ContractPageSerializer, CreateB2BEnrollmentSerializer, + DataConsentSerializer, + DataConsentValidationErrorSerializer, OrganizationPageSerializer, ) from courses.models import CourseRun @@ -371,3 +373,36 @@ def _attach_user_to_contracts(self, user, contracts, code): user.save() return contracts_attached, contract_full + + +class DataConsentAPI(APIView): + """View for recording data consent for a user on a contract.""" + + permission_classes = [IsAuthenticated] + + @extend_schema( + request=DataConsentSerializer, + responses={ + 204: None, + 400: DataConsentValidationErrorSerializer, + 403: None, + }, + ) + def post(self, request, contract_id: int): + + user = request.user + b2b_contract_membership = user.b2b_contracts.through.objects.filter( + contract_page=contract_id + ).first() + if not b2b_contract_membership: + # Users shouldn't be able to provide data consent for contracts they're not in + return Response(status=status.HTTP_403_FORBIDDEN) + + request_serializer = DataConsentSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + consent_value = request_serializer.validated_data["consented"] + b2b_contract_membership.consented_to_data_sharing = consent_value + b2b_contract_membership.consent_modified_at = now_in_utc() + b2b_contract_membership.save() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/b2b/views/v0/urls.py b/b2b/views/v0/urls.py index 1ca8b795ee..c88aaa4da0 100644 --- a/b2b/views/v0/urls.py +++ b/b2b/views/v0/urls.py @@ -5,6 +5,7 @@ from b2b.views.v0 import ( AttachContractApi, ContractPageViewSet, + DataConsentAPI, Enroll, OrganizationPageViewSet, ) @@ -80,7 +81,6 @@ AttachContractApi.as_view(), name="attach-user", ), - # Probably not the place this is gonna live long term. path(r"webhook", ProcessMailgunWebhook.as_view(), name="mailgun-webhook"), # Service-to-service; delete along with b2b/views/v0/service.py once # org-manager status is visible in Keycloak (mitodl/hq#10594). @@ -89,4 +89,9 @@ OrganizationManagerCheckView.as_view(), name="service-organization-manager-check", ), + path( + r"data_consent//", + DataConsentAPI.as_view(), + name="data-consent", + ), ] diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index dc3c0fa578..0e293f7c40 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -209,6 +209,41 @@ paths: schema: $ref: '#/components/schemas/ContractPageVariantRunBadRequest' description: '' + /api/v0/b2b/data_consent/{contract_id}/: + post: + operationId: b2b_data_consent_create + description: View for recording data consent for a user on a contract. + parameters: + - in: path + name: contract_id + schema: + type: integer + required: true + tags: + - b2b + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DataConsentRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/DataConsentRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/DataConsentRequest' + required: true + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/DataConsentValidationError' + description: '' + '403': + description: No response body /api/v0/b2b/enroll/{readable_id}/: post: operationId: b2b_enroll_create @@ -6964,6 +6999,24 @@ components: required: - name - org_key + DataConsentRequest: + type: object + description: Records whether a user has consented to data sharing for a contract + properties: + consented: + type: boolean + required: + - consented + DataConsentValidationError: + type: object + description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer. + properties: + consented: + type: array + items: + type: string + description: Errors for the 'consented' field, e.g. if missing or not a + boolean. Department: type: object description: Department model serializer diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index bdf4838656..4144141545 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -209,6 +209,41 @@ paths: schema: $ref: '#/components/schemas/ContractPageVariantRunBadRequest' description: '' + /api/v0/b2b/data_consent/{contract_id}/: + post: + operationId: b2b_data_consent_create + description: View for recording data consent for a user on a contract. + parameters: + - in: path + name: contract_id + schema: + type: integer + required: true + tags: + - b2b + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DataConsentRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/DataConsentRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/DataConsentRequest' + required: true + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/DataConsentValidationError' + description: '' + '403': + description: No response body /api/v0/b2b/enroll/{readable_id}/: post: operationId: b2b_enroll_create @@ -6964,6 +6999,24 @@ components: required: - name - org_key + DataConsentRequest: + type: object + description: Records whether a user has consented to data sharing for a contract + properties: + consented: + type: boolean + required: + - consented + DataConsentValidationError: + type: object + description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer. + properties: + consented: + type: array + items: + type: string + description: Errors for the 'consented' field, e.g. if missing or not a + boolean. Department: type: object description: Department model serializer diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index a8d17bc0f6..2bbd7998a9 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -209,6 +209,41 @@ paths: schema: $ref: '#/components/schemas/ContractPageVariantRunBadRequest' description: '' + /api/v0/b2b/data_consent/{contract_id}/: + post: + operationId: b2b_data_consent_create + description: View for recording data consent for a user on a contract. + parameters: + - in: path + name: contract_id + schema: + type: integer + required: true + tags: + - b2b + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DataConsentRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/DataConsentRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/DataConsentRequest' + required: true + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/DataConsentValidationError' + description: '' + '403': + description: No response body /api/v0/b2b/enroll/{readable_id}/: post: operationId: b2b_enroll_create @@ -6964,6 +6999,24 @@ components: required: - name - org_key + DataConsentRequest: + type: object + description: Records whether a user has consented to data sharing for a contract + properties: + consented: + type: boolean + required: + - consented + DataConsentValidationError: + type: object + description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer. + properties: + consented: + type: array + items: + type: string + description: Errors for the 'consented' field, e.g. if missing or not a + boolean. Department: type: object description: Department model serializer From 4ece6f6685b7b215d144a59911ac24f564680443 Mon Sep 17 00:00:00 2001 From: dansubak Date: Wed, 16 Sep 2026 12:07:17 -0400 Subject: [PATCH 2/2] Revise error serializer --- b2b/serializers/v0/__init__.py | 15 +++++--- b2b/views/v0/views_test.py | 65 +++++++++++++++++++++++++++++++++- openapi/specs/v0.yaml | 19 ++++++---- openapi/specs/v1.yaml | 19 ++++++---- openapi/specs/v2.yaml | 19 ++++++---- 5 files changed, 113 insertions(+), 24 deletions(-) diff --git a/b2b/serializers/v0/__init__.py b/b2b/serializers/v0/__init__.py index a839d748b8..71daa3518e 100644 --- a/b2b/serializers/v0/__init__.py +++ b/b2b/serializers/v0/__init__.py @@ -1,6 +1,6 @@ """Serializers for the B2B API (v0).""" -from drf_spectacular.utils import extend_schema_field +from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers from b2b.models import ContractPage, OrganizationPage @@ -183,8 +183,13 @@ class DataConsentSerializer(serializers.Serializer): class DataConsentValidationErrorSerializer(serializers.Serializer): """Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer.""" - consented = serializers.ListField( - child=serializers.CharField(), - required=False, - help_text="Errors for the 'consented' field, e.g. if missing or not a boolean.", + errors = inline_serializer( + name="DataConsentFieldErrors", + fields={ + "consented": serializers.ListField( + child=serializers.CharField(), + required=False, + help_text="Errors for the 'consented' field, e.g. if missing or not a boolean.", + ) + }, ) diff --git a/b2b/views/v0/views_test.py b/b2b/views/v0/views_test.py index 6dabaa199c..224d74509e 100644 --- a/b2b/views/v0/views_test.py +++ b/b2b/views/v0/views_test.py @@ -17,7 +17,11 @@ CONTRACT_MEMBERSHIP_MANAGED, ) from b2b.factories import ContractPageFactory -from b2b.models import DiscountContractAttachmentRedemption, UserOrganization +from b2b.models import ( + DiscountContractAttachmentRedemption, + UserB2BContract, + UserOrganization, +) from courses.factories import CourseRunFactory from courses.models import CourseRunEnrollment from ecommerce.factories import ProductFactory, UnlimitedUseDiscountFactory @@ -859,3 +863,62 @@ def test_enroll_courserun_without_b2b_contract_not_found(mocker): url = reverse("b2b:enroll-user", kwargs={"readable_id": courserun.courseware_id}) with pytest.raises(Exception): # noqa: B017, PT011 client.post(url) + + +def test_data_consent_forbidden_when_not_a_contract_member(user): + """A user who isn't attached to the contract should get a 403.""" + contract = ContractPageFactory.create() + client = APIClient() + client.force_login(user) + + url = reverse("b2b:data-consent", kwargs={"contract_id": contract.id}) + resp = client.post(url, data={"consented": True}, format="json") + + assert resp.status_code == 403 + assert not UserB2BContract.objects.filter( + user=user, contract_page=contract + ).exists() + + +def test_data_consent_invalid_body(user): + """A request missing the required 'consented' field should return 400.""" + contract = ContractPageFactory.create() + user.b2b_contracts.add(contract) + + client = APIClient() + client.force_login(user) + + url = reverse("b2b:data-consent", kwargs={"contract_id": contract.id}) + resp = client.post(url, data={}, format="json") + + assert resp.status_code == 400 + assert "consented" in resp.json()["errors"] + + membership = UserB2BContract.objects.get(user=user, contract_page=contract) + assert membership.consented_to_data_sharing is None + assert membership.consent_modified_at is None + + +def test_data_consent_success(user): + """A contract member can set consent, and later change their mind.""" + contract = ContractPageFactory.create() + user.b2b_contracts.add(contract) + + client = APIClient() + client.force_login(user) + + url = reverse("b2b:data-consent", kwargs={"contract_id": contract.id}) + + resp = client.post(url, data={"consented": True}, format="json") + assert resp.status_code == 204 + membership = UserB2BContract.objects.get(user=user, contract_page=contract) + assert membership.consented_to_data_sharing is True + first_modified_at = membership.consent_modified_at + assert first_modified_at is not None + + resp = client.post(url, data={"consented": False}, format="json") + assert resp.status_code == 204 + membership.refresh_from_db() + assert membership.consented_to_data_sharing is False + assert membership.consent_modified_at is not None + assert membership.consent_modified_at >= first_modified_at diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 0e293f7c40..da699a66de 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -6999,6 +6999,15 @@ components: required: - name - org_key + DataConsentFieldErrors: + type: object + properties: + consented: + type: array + items: + type: string + description: Errors for the 'consented' field, e.g. if missing or not a + boolean. DataConsentRequest: type: object description: Records whether a user has consented to data sharing for a contract @@ -7011,12 +7020,10 @@ components: type: object description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer. properties: - consented: - type: array - items: - type: string - description: Errors for the 'consented' field, e.g. if missing or not a - boolean. + errors: + $ref: '#/components/schemas/DataConsentFieldErrors' + required: + - errors Department: type: object description: Department model serializer diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 4144141545..1e5fb4edce 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -6999,6 +6999,15 @@ components: required: - name - org_key + DataConsentFieldErrors: + type: object + properties: + consented: + type: array + items: + type: string + description: Errors for the 'consented' field, e.g. if missing or not a + boolean. DataConsentRequest: type: object description: Records whether a user has consented to data sharing for a contract @@ -7011,12 +7020,10 @@ components: type: object description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer. properties: - consented: - type: array - items: - type: string - description: Errors for the 'consented' field, e.g. if missing or not a - boolean. + errors: + $ref: '#/components/schemas/DataConsentFieldErrors' + required: + - errors Department: type: object description: Department model serializer diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 2bbd7998a9..8543b15b89 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -6999,6 +6999,15 @@ components: required: - name - org_key + DataConsentFieldErrors: + type: object + properties: + consented: + type: array + items: + type: string + description: Errors for the 'consented' field, e.g. if missing or not a + boolean. DataConsentRequest: type: object description: Records whether a user has consented to data sharing for a contract @@ -7011,12 +7020,10 @@ components: type: object description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer. properties: - consented: - type: array - items: - type: string - description: Errors for the 'consented' field, e.g. if missing or not a - boolean. + errors: + $ref: '#/components/schemas/DataConsentFieldErrors' + required: + - errors Department: type: object description: Department model serializer