Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion b2b/serializers/v0/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -169,3 +169,27 @@ 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."""

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.",
)
},
)
35 changes: 35 additions & 0 deletions b2b/views/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
B2BEnrollRequestSerializer,
ContractPageSerializer,
CreateB2BEnrollmentSerializer,
DataConsentSerializer,
DataConsentValidationErrorSerializer,
OrganizationPageSerializer,
)
from courses.models import CourseRun
Expand Down Expand Up @@ -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)
7 changes: 6 additions & 1 deletion b2b/views/v0/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from b2b.views.v0 import (
AttachContractApi,
ContractPageViewSet,
DataConsentAPI,
Enroll,
OrganizationPageViewSet,
)
Expand Down Expand Up @@ -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).
Expand All @@ -89,4 +89,9 @@
OrganizationManagerCheckView.as_view(),
name="service-organization-manager-check",
),
path(
r"data_consent/<int:contract_id>/",
DataConsentAPI.as_view(),
name="data-consent",
),
]
65 changes: 64 additions & 1 deletion b2b/views/v0/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
60 changes: 60 additions & 0 deletions openapi/specs/v0.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -6964,6 +6999,31 @@ 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
properties:
consented:
type: boolean
required:
- consented
DataConsentValidationError:
type: object
description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer.
properties:
errors:
$ref: '#/components/schemas/DataConsentFieldErrors'
required:
- errors
Department:
type: object
description: Department model serializer
Expand Down
60 changes: 60 additions & 0 deletions openapi/specs/v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -6964,6 +6999,31 @@ 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
properties:
consented:
type: boolean
required:
- consented
DataConsentValidationError:
type: object
description: Default DRF is_valid(raise_exception=True) error shape for DataConsentSerializer.
properties:
errors:
$ref: '#/components/schemas/DataConsentFieldErrors'
required:
- errors
Department:
type: object
description: Department model serializer
Expand Down
Loading
Loading