From cf4099d13f5e70fe932b0dc015659dafc6319850 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Tue, 9 Dec 2025 14:44:10 -0500 Subject: [PATCH 1/3] feat: add GDPR consent status to CatalogLearner model --- partner_catalog/admin.py | 14 ++++++++++- ...loglearner_gdpr_consent_status_and_more.py | 23 +++++++++++++++++++ partner_catalog/models.py | 19 +++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 partner_catalog/migrations/0007_cataloglearner_gdpr_consent_status_and_more.py diff --git a/partner_catalog/admin.py b/partner_catalog/admin.py index 57ed517..fbf93b8 100644 --- a/partner_catalog/admin.py +++ b/partner_catalog/admin.py @@ -389,11 +389,12 @@ class CatalogLearnerAdmin(admin.ModelAdmin): "user_email", "catalog", "active", + "gdpr_consent_status", "invited_at", "removed_at", "created_at", ] - list_filter = ["catalog__partner", "active"] + list_filter = ["catalog__partner", "active", "gdpr_consent_status"] search_fields = ["user__username", "user__email", "catalog__name"] ordering = ["catalog__name", "user__username"] date_hierarchy = "created_at" @@ -412,11 +413,22 @@ def has_change_permission(self, request, obj=None): "removed_at", "invited_by_display", "removed_by_display", + "gdpr_consent_status", + "gdpr_consent_updated_at", ] fieldsets = ( ("Learner Assignment", {"fields": ("catalog", "user", "current_invitation")}), ("Status", {"fields": ("active", "created_at")}), + ( + "GDPR Consent", + { + "fields": ( + "gdpr_consent_status", + "gdpr_consent_updated_at", + ) + }, + ), ( "Invitation Details", { diff --git a/partner_catalog/migrations/0007_cataloglearner_gdpr_consent_status_and_more.py b/partner_catalog/migrations/0007_cataloglearner_gdpr_consent_status_and_more.py new file mode 100644 index 0000000..9b33249 --- /dev/null +++ b/partner_catalog/migrations/0007_cataloglearner_gdpr_consent_status_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.20 on 2025-12-09 18:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner_catalog', '0006_partnercatalog_image'), + ] + + operations = [ + migrations.AddField( + model_name='cataloglearner', + name='gdpr_consent_status', + field=models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected')], default='pending', help_text='GDPR consent status. Course enrollment is blocked until accepted.', max_length=10), + ), + migrations.AddField( + model_name='cataloglearner', + name='gdpr_consent_updated_at', + field=models.DateTimeField(blank=True, help_text='Timestamp when GDPR consent status was last updated.', null=True), + ), + ] diff --git a/partner_catalog/models.py b/partner_catalog/models.py index 79753b6..88f5ce0 100644 --- a/partner_catalog/models.py +++ b/partner_catalog/models.py @@ -363,6 +363,13 @@ class CatalogLearner(models.Model): the catalog in CatalogLearnerInvitation. """ + class GDPRConsentStatus(models.TextChoices): + """Possible GDPR consent statuses for a learner.""" + + PENDING = "pending", "Pending" + ACCEPTED = "accepted", "Accepted" + REJECTED = "rejected", "Rejected" + id = models.AutoField(primary_key=True) catalog = models.ForeignKey( "PartnerCatalog", @@ -381,6 +388,18 @@ class CatalogLearner(models.Model): related_name="current_for_learner", ) + gdpr_consent_status = models.CharField( + max_length=10, + choices=GDPRConsentStatus.choices, + default=GDPRConsentStatus.PENDING, + help_text="GDPR consent status. Course enrollment is blocked until accepted.", + ) + gdpr_consent_updated_at = models.DateTimeField( + null=True, + blank=True, + help_text="Timestamp when GDPR consent status was last updated.", + ) + class Meta: """Meta options for CatalogLearner model.""" From 558fc9503b9301103c0b5e5c941de6f443ef59cf Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Tue, 9 Dec 2025 14:46:27 -0500 Subject: [PATCH 2/3] feat: implement GDPR consent check in course enrollment logic --- partner_catalog/policies/enrollments.py | 31 +++++++++++++++++++++++-- partner_catalog/services/enrollments.py | 3 ++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/partner_catalog/policies/enrollments.py b/partner_catalog/policies/enrollments.py index 81e3c7f..28c1ad6 100644 --- a/partner_catalog/policies/enrollments.py +++ b/partner_catalog/policies/enrollments.py @@ -3,10 +3,32 @@ from django.contrib.auth import get_user_model from partner_catalog.edxapp_wrapper.enrollment_api import get_enrollment +from partner_catalog.models import CatalogLearner from partner_catalog.policies.catalogs import is_user_an_active_catalog_learner -def can_user_enroll_in_catalog_course(*, user, catalog_course) -> bool: +def has_accepted_gdpr_consent(*, user, catalog) -> bool: + """ + Check if the user has accepted GDPR consent for the given catalog. + + Args: + user: The user object to check. + catalog: The catalog instance to check GDPR consent for. + + Returns: + True if the user has accepted GDPR consent, False otherwise. + """ + learner = CatalogLearner.objects.filter( + catalog=catalog, user=user, active=True + ).first() + + if not learner: + return False + + return learner.gdpr_consent_status == CatalogLearner.GDPRConsentStatus.ACCEPTED + + +def can_user_enroll_in_catalog_course(*, user, catalog_course, gdpr_required=True) -> bool: """ Determine if a user is allowed to enroll in an specific catalog course. @@ -19,6 +41,7 @@ def can_user_enroll_in_catalog_course(*, user, catalog_course) -> bool: Access is granted if: - The user is an active learner in the catalog associated. - The catalog is currently active. + - The user has accepted GDPR consent. - The catalog has remaining course enrollment capacity. """ @@ -28,6 +51,10 @@ def can_user_enroll_in_catalog_course(*, user, catalog_course) -> bool: if not is_user_an_active_catalog_learner(user=user, catalog=catalog): return False + + if gdpr_required and not has_accepted_gdpr_consent(user=user, catalog=catalog): + return False + return True @@ -45,6 +72,6 @@ def has_an_edx_platform_enrollment(*, user_id: int, course_id) -> bool: User = get_user_model() user = User.objects.only("id", "username").get(pk=user_id) - enrollment = get_enrollment(username=user.username, course_id=course_id) + enrollment = get_enrollment(username=user.username, course_id=str(course_id)) return enrollment is not None diff --git a/partner_catalog/services/enrollments.py b/partner_catalog/services/enrollments.py index 62ccb40..7a97b67 100644 --- a/partner_catalog/services/enrollments.py +++ b/partner_catalog/services/enrollments.py @@ -23,7 +23,7 @@ class CatalogCourseEnrollmentService: @transaction.atomic def create_or_activate_course_enrollment( - self, *, user_id, catalog_course_id + self, *, user_id, catalog_course_id, gdpr_required=True ) -> CatalogCourseEnrollment: """ Create or activate a catalog course enrollment for a user. @@ -42,6 +42,7 @@ def create_or_activate_course_enrollment( if not can_user_enroll_in_catalog_course( user=User.objects.get(id=user_id), catalog_course=catalog_course, + gdpr_required=gdpr_required, ): raise ValidationError(self.ERROR_USER_NOT_ALLOWED_TO_ENROLL) From fb236444520e0579be5a3fb7084dcc06834e28c9 Mon Sep 17 00:00:00 2001 From: Andres Felipe Giraldo Date: Tue, 9 Dec 2025 14:46:46 -0500 Subject: [PATCH 3/3] feat: implement GDPR consent management in learner views --- partner_catalog/api/v1/learner_views.py | 49 +++++++++++++++++++ partner_catalog/api/v1/serializers.py | 32 +++++++++++++ partner_catalog/services/catalogs.py | 62 +++++++++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/partner_catalog/api/v1/learner_views.py b/partner_catalog/api/v1/learner_views.py index c0dfa5e..8ad421f 100644 --- a/partner_catalog/api/v1/learner_views.py +++ b/partner_catalog/api/v1/learner_views.py @@ -6,6 +6,7 @@ from django.db.models import Count, Exists, OuterRef, Q from django_filters.rest_framework import DjangoFilterBackend +from drf_spectacular.utils import extend_schema from edx_rest_framework_extensions.permissions import IsAuthenticated from rest_framework import filters, status, viewsets from rest_framework.decorators import action @@ -16,6 +17,7 @@ from partner_catalog.api.v1.serializers import ( CatalogLearnerInvitationSerializer, CatalogLearnerSerializer, + GDPRConsentSerializer, LearnerCatalogCourseSerializer, LearnerPartnerCatalogSerializer, ) @@ -118,6 +120,53 @@ def decline(self, request, **kwargs): serializer = CatalogLearnerInvitationSerializer(invitation) return Response(serializer.data, status=status.HTTP_200_OK) + @extend_schema( + request=GDPRConsentSerializer, + responses={200: GDPRConsentSerializer}, + description="Get or update GDPR consent status for the current user in this catalog.", + ) + @action(detail=True, methods=["get", "post"], url_path="gdpr_consent") + def gdpr_consent(self, request, **kwargs): + """ + Get or update GDPR consent status for the current user in this catalog. + + GET: Returns current GDPR consent status. + POST: Updates GDPR consent status. Accepts 'gdpr_consent_status' in request body + with values: 'accepted' or 'rejected'. + - If 'accepted': Updates the consent status. + - If 'rejected': Updates the consent status and removes the user from the catalog. + + The user must be enrolled in the catalog (active learner) to access this endpoint. + Course enrollment is blocked until GDPR consent is accepted. + """ + catalog = self.get_object() + user = request.user + + if request.method == "GET": + try: + learner = CatalogLearner.objects.get(catalog=catalog, user=user, active=True) + except CatalogLearner.DoesNotExist: + return Response( + {"detail": "User is not an active learner in this catalog."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = GDPRConsentSerializer(learner) + return Response(serializer.data) + + # POST method - update consent status via service + serializer = GDPRConsentSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + new_status = serializer.validated_data["gdpr_consent_status"] + + if new_status == CatalogLearner.GDPRConsentStatus.ACCEPTED: + learner = self.catalog_service.accept_gdpr_consent(user=user, catalog=catalog) + else: + learner = self.catalog_service.reject_gdpr_consent(user=user, catalog=catalog) + + output_serializer = GDPRConsentSerializer(learner) + return Response(output_serializer.data) + class LearnerCatalogCourseViewSet(InjectNestedFKMixin, viewsets.ReadOnlyModelViewSet): """ diff --git a/partner_catalog/api/v1/serializers.py b/partner_catalog/api/v1/serializers.py index be034e5..2e84f25 100644 --- a/partner_catalog/api/v1/serializers.py +++ b/partner_catalog/api/v1/serializers.py @@ -273,6 +273,8 @@ class CatalogLearnerSerializer(serializers.ModelSerializer): ) enrollments = serializers.IntegerField(source="enrollments_count", read_only=True) certified = serializers.IntegerField(source="certified_count", read_only=True) + gdpr_consent_status = serializers.CharField(read_only=True) + gdpr_consent_updated_at = serializers.DateTimeField(read_only=True) class Meta: model = CatalogLearner @@ -287,10 +289,40 @@ class Meta: "removed_at", "enrollments", "certified", + "gdpr_consent_status", + "gdpr_consent_updated_at", ] read_only_fields = ["id"] +class GDPRConsentSerializer(serializers.Serializer): + """ + Serializer for GDPR consent status. + + Used for both input (updating status) and output (returning current status). + - Input: Accepts 'gdpr_consent_status' with values 'accepted' or 'rejected'. + - Output: Returns 'gdpr_consent_status' and 'gdpr_consent_updated_at' from learner instance. + """ + + gdpr_consent_status = serializers.ChoiceField( + choices=[ + (CatalogLearner.GDPRConsentStatus.ACCEPTED, "Accepted"), + (CatalogLearner.GDPRConsentStatus.REJECTED, "Rejected"), + (CatalogLearner.GDPRConsentStatus.PENDING, "Pending"), + ], + help_text="GDPR consent status.", + ) + gdpr_consent_updated_at = serializers.DateTimeField(read_only=True) + + def create(self, validated_data): + """No-op: this serializer is not used to create DB objects.""" + return validated_data + + def update(self, instance, validated_data): + """No-op: this serializer does not mutate instances directly.""" + return instance + + class CatalogCourseSerializer(serializers.ModelSerializer): """Serializer for courses in a corporate partner catalog.""" diff --git a/partner_catalog/services/catalogs.py b/partner_catalog/services/catalogs.py index 0ac5fef..05611d4 100644 --- a/partner_catalog/services/catalogs.py +++ b/partner_catalog/services/catalogs.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction +from django.utils import timezone from rest_framework.exceptions import ValidationError from partner_catalog.edxapp_wrapper.course_module import course_overview @@ -112,6 +113,67 @@ def unenroll_user_from_catalog(self, user, catalog): learner.refresh_from_db() return learner + @transaction.atomic + def accept_gdpr_consent(self, user, catalog) -> CatalogLearner: + """ + Accept GDPR consent for a user in a catalog. + + Args: + user: The user accepting GDPR consent. + catalog: The catalog for which to accept GDPR consent. + Returns: + The updated CatalogLearner instance. + Raises: + ValidationError: If the user is not an active learner in the catalog. + """ + if not is_user_an_active_catalog_learner(user=user, catalog=catalog): + raise ValidationError(self.ERROR_USER_NOT_ENROLLED) + + learner = CatalogLearner.objects.get(catalog=catalog, user=user, active=True) + learner.gdpr_consent_status = CatalogLearner.GDPRConsentStatus.ACCEPTED + learner.gdpr_consent_updated_at = timezone.now() + learner.save(update_fields=["gdpr_consent_status", "gdpr_consent_updated_at"]) + + return learner + + @transaction.atomic + def reject_gdpr_consent(self, user, catalog) -> CatalogLearner: + """ + Reject GDPR consent for a user in a catalog. + + This will mark the GDPR consent as rejected and remove the user + from the catalog by revoking their invitation. + + Args: + user: The user rejecting GDPR consent. + catalog: The catalog for which to reject GDPR consent. + Returns: + The deactivated CatalogLearner instance. + Raises: + ValidationError: If the user is not an active learner in the catalog. + """ + if not is_user_an_active_catalog_learner(user=user, catalog=catalog): + raise ValidationError(self.ERROR_USER_NOT_ENROLLED) + + learner = CatalogLearner.objects.select_related('current_invitation').get( + catalog=catalog, user=user, active=True + ) + + # Update GDPR consent status to rejected + learner.gdpr_consent_status = CatalogLearner.GDPRConsentStatus.REJECTED + learner.gdpr_consent_updated_at = timezone.now() + learner.save(update_fields=["gdpr_consent_status", "gdpr_consent_updated_at"]) + + # Remove the user from the catalog + invitation_service = CatalogLearnerInvitationService() + invitation_service.remove_invitation( + invitation_id=learner.current_invitation.id, + user=user + ) + + learner.refresh_from_db() + return learner + @transaction.atomic def decline_catalog_invitation(self, user, catalog) -> CatalogLearnerInvitation: """