Skip to content
Open
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
14 changes: 13 additions & 1 deletion partner_catalog/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
{
Expand Down
49 changes: 49 additions & 0 deletions partner_catalog/api/v1/learner_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,6 +17,7 @@
from partner_catalog.api.v1.serializers import (
CatalogLearnerInvitationSerializer,
CatalogLearnerSerializer,
GDPRConsentSerializer,
LearnerCatalogCourseSerializer,
LearnerPartnerCatalogSerializer,
)
Expand Down Expand Up @@ -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):
"""
Expand Down
32 changes: 32 additions & 0 deletions partner_catalog/api/v1/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""

Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
19 changes: 19 additions & 0 deletions partner_catalog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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."""

Expand Down
31 changes: 29 additions & 2 deletions partner_catalog/policies/enrollments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
"""

Expand All @@ -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


Expand All @@ -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
62 changes: 62 additions & 0 deletions partner_catalog/services/catalogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down
3 changes: 2 additions & 1 deletion partner_catalog/services/enrollments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)

Expand Down
Loading