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
8 changes: 6 additions & 2 deletions course_discovery/apps/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ class CourseRunFilter(FilterSetMixin, filters.FilterSet):
marketable = filters.BooleanFilter(method='filter_marketable')
keys = CharListFilter(field_name='key', lookup_expr='in')
license = filters.CharFilter(field_name='license', lookup_expr='iexact')
credit_provider = filters.CharFilter(
field_name='seats__credit_provider',
lookup_expr='iexact',
help_text="Filter course runs by credit provider (for credit seats only).")

@property
def qs(self):
Expand All @@ -148,11 +152,11 @@ def qs(self):
if not isinstance(self.queryset, QuerySet):
return self.queryset

return super().qs
return super().qs.distinct()

class Meta:
model = CourseRun
fields = ('keys', 'hidden', 'license',)
fields = ('keys', 'hidden', 'license', 'credit_provider',)


class ProgramFilter(FilterSetMixin, filters.FilterSet):
Expand Down
96 changes: 88 additions & 8 deletions course_discovery/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from django.contrib.auth import get_user_model
from django.db.models import Count
from django.db.models.query import Prefetch
from django.utils import timezone
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django_countries.serializer_fields import CountryField
Expand Down Expand Up @@ -800,12 +801,12 @@ class SeatSerializer(BaseModelSerializer):
min_value=0,
)
currency = serializers.SlugRelatedField(read_only=True, slug_field='code')
upgrade_deadline = serializers.DateTimeField()
upgrade_deadline_override = serializers.DateTimeField()
credit_provider = serializers.CharField()
credit_hours = serializers.IntegerField()
sku = serializers.CharField()
bulk_sku = serializers.CharField()
upgrade_deadline = serializers.DateTimeField(required=False, allow_null=True)
upgrade_deadline_override = serializers.DateTimeField(required=False, allow_null=True)
credit_provider = serializers.CharField(required=False, allow_null=True, allow_blank=True)
credit_hours = serializers.IntegerField(required=False, allow_null=True)
sku = serializers.CharField(required=False, allow_null=True, allow_blank=True)
bulk_sku = serializers.CharField(required=False, allow_null=True, allow_blank=True)

@classmethod
def prefetch_queryset(cls):
Expand All @@ -819,6 +820,14 @@ class Meta:
'credit_hours', 'sku', 'bulk_sku'
)

def to_representation(self, instance):
data = super().to_representation(instance)
if instance.type and instance.type.slug != Seat.CREDIT:
data.pop('credit_provider', None)
data.pop('credit_hours', None)
data.pop('upgrade_deadline', None)
return data


class CourseEntitlementSerializer(BaseModelSerializer):
"""Serializer for the ``CourseEntitlement`` model."""
Expand Down Expand Up @@ -966,7 +975,6 @@ def prefetch_queryset(cls, queryset=None):
'_official_version',
'course__partner',
'restricted_run',
Prefetch('seats', queryset=SeatSerializer.prefetch_queryset()),
)

class Meta:
Expand Down Expand Up @@ -1074,6 +1082,7 @@ class CourseRunSerializer(MinimalCourseRunSerializer):
)
estimated_hours = serializers.SerializerMethodField()
enterprise_subscription_inclusion = serializers.BooleanField(required=False)
seats = SeatSerializer(many=True, required=False)

@classmethod
def prefetch_queryset(cls, queryset=None):
Expand All @@ -1093,8 +1102,48 @@ def prefetch_queryset(cls, queryset=None):
'video__image',
'language__translations',
Prefetch('staff', queryset=MinimalPersonSerializer.prefetch_queryset()),
Prefetch('seats', queryset=SeatSerializer.prefetch_queryset()),
)

def validate_seats(self, seats):
"""
Validate credit seat metadata:
- Prevent duplicate credit providers for credit seats.
- Ensure credit_hours is positive if a provider is set.
- Ensure non-credit seats do not carry credit metadata.
"""
providers = []
for s in seats:
# normalize type: could be SeatType object or slug string
seat_type = getattr(s.get('type'), 'slug', s.get('type'))
if seat_type == Seat.CREDIT:
provider = s.get('credit_provider')
hours = s.get('credit_hours')
if provider:
# check for duplicates
if provider in providers:
raise serializers.ValidationError(
f"Duplicate credit provider(s): {provider}"
)
providers.append(provider)
# check credit_hours
if hours is None or hours <= 0:
raise serializers.ValidationError(
f"Credit hours must be a positive integer for provider {provider}"
)
else:
# Non-credit seat should not include credit metadata
if s.get('credit_provider') or s.get('credit_hours'):
raise serializers.ValidationError(
"Non-credit seats cannot include credit metadata fields."
)
upgrade_deadline_override = s.get('upgrade_deadline_override')
if upgrade_deadline_override and upgrade_deadline_override < timezone.now():
raise serializers.ValidationError(
"Upgrade deadline override cannot be in the past."
)
return seats

class Meta(MinimalCourseRunSerializer.Meta):
fields = MinimalCourseRunSerializer.Meta.fields + (
'course', 'full_description', 'announcement', 'video', 'seats', 'content_language', 'license', 'outcome',
Expand Down Expand Up @@ -1144,7 +1193,38 @@ def update(self, instance, validated_data):
# Handle writing nested video data separately
if 'get_video' in validated_data:
self.update_video(instance, validated_data.pop('get_video'))
return super().update(instance, validated_data)
seats_data = validated_data.pop('seats', None)
instance = super().update(instance, validated_data)
if seats_data is not None:
for seat in seats_data:
seat_type_value = seat.get('type')
if isinstance(seat_type_value, str):
seat_type = SeatType.objects.filter(slug=seat_type_value).first()
elif hasattr(seat_type_value, 'slug'):
seat_type = seat_type_value
else:
seat_type = None

if not seat_type:
continue # skip invalid seat data safely

defaults = {
'price': seat.get('price', 0),
'upgrade_deadline_override': seat.get('upgrade_deadline_override'),
'credit_provider': seat.get('credit_provider'),
'credit_hours': seat.get('credit_hours'),
}

obj_seat, created = instance.seats.get_or_create(type=seat_type, defaults=defaults)

if not created:
for field, value in defaults.items():
# Only overwrite if explicitly passed in seat data
if field in seat:
setattr(obj_seat, field, value)
obj_seat.full_clean() # ensure model validation runs
obj_seat.save()
return instance

def validate(self, attrs):
course = attrs.get('course', None)
Expand Down
127 changes: 126 additions & 1 deletion course_discovery/apps/api/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from urllib.parse import urlencode

import ddt
import pytest
import responses
from django.test import TestCase
from django.utils.text import slugify
Expand Down Expand Up @@ -54,6 +55,7 @@
PersonSearchDocumentSerializer, PersonSearchModelSerializer, ProgramSearchDocumentSerializer,
ProgramSearchModelSerializer
)
from course_discovery.apps.course_metadata.tests import factories
from course_discovery.apps.course_metadata.tests.factories import (
AdditionalMetadataFactory, AdditionalPromoAreaFactory, BulkOperationTaskFactory, CertificateInfoFactory,
CollaboratorFactory, CorporateEndorsementFactory, CourseEditorFactory, CourseEntitlementFactory, CourseFactory,
Expand Down Expand Up @@ -763,7 +765,23 @@ def get_expected_data(cls, course_run, request):
'weeks_to_complete': course_run.weeks_to_complete,
'instructors': [],
'staff': [],
'seats': [],
'seats': [
{
'type': seat.type.slug,
'price': str(seat.price),
'currency': seat.currency.code,
'upgrade_deadline': json_date_format(seat.upgrade_deadline),
'upgrade_deadline_override': (
json_date_format(seat.upgrade_deadline_override)
if seat.upgrade_deadline_override else None
),
'credit_provider': seat.credit_provider,
'credit_hours': seat.credit_hours,
'sku': seat.sku,
'bulk_sku': seat.bulk_sku,
}
for seat in course_run.seats.all()
],
'modified': json_date_format(course_run.modified),
'level_type': course_run.level_type.name_t,
'availability': course_run.availability,
Expand Down Expand Up @@ -822,6 +840,84 @@ def test_draft_and_official(self):
assert serializer.data['marketing_url'] is not None
assert serializer.data['marketing_url'] == course_run.marketing_url

@pytest.mark.django_db
def test_course_run_serializer_includes_credit_seats_info(self):
"""CourseRunSerializer should include seats with credit info."""
credit_type = factories.SeatTypeFactory.credit()
cr = factories.CourseRunFactory()
factories.SeatFactory(
course_run=cr,
type=credit_type,
price=150,
credit_provider="providerA",
credit_hours=5,
)
request = make_request()
data = CourseRunSerializer(cr, context={'request': request}).data
seats = data.get("seats", [])
assert seats, "Expected seats to be present"
seat0 = seats[0]
assert seat0["credit_provider"] == "providerA"
assert seat0["credit_hours"] == 5

@pytest.mark.django_db
def test_duplicate_credit_provider_validation_in_serializer(self):
"""Serializer must reject payloads that try to assign same credit_provider twice for same run."""
credit_type = factories.SeatTypeFactory.credit()
cr = factories.CourseRunFactory()
request = make_request()
payload = {
"seats": [
{"type": credit_type.slug, "price": 100, "credit_provider": "X", "credit_hours": 2},
{"type": credit_type.slug, "price": 120, "credit_provider": "X", "credit_hours": 4},
]
}
serializer = CourseRunSerializer(instance=cr, data=payload, partial=True, context={'request': request})
assert not serializer.is_valid(), "Serializer should reject duplicate credit_provider"
errors = serializer.errors.get("seats") or serializer.errors
assert "Duplicate credit provider" in str(errors)

@pytest.mark.django_db
def test_non_credit_seat_with_credit_fields_rejected(self):
"""Serializer should reject non-credit seats carrying credit metadata."""
verified_type = factories.SeatTypeFactory.verified()
cr = factories.CourseRunFactory()
request = make_request()
payload = {
"seats": [
{"type": verified_type.slug, "price": 100, "credit_provider": "BadProv", "credit_hours": 2}
]
}
serializer = CourseRunSerializer(instance=cr, data=payload, partial=True, context={'request': request})
assert not serializer.is_valid(), "Serializer should reject credit metadata for non-credit seat"
assert "Non-credit seats cannot include credit metadata" in str(serializer.errors)

@pytest.mark.django_db
def test_update_credit_fields_via_course_run_serializer(self):
"""Updating an existing seat via CourseRunSerializer should change credit_provider/credit_hours."""
credit_type = factories.SeatTypeFactory.credit()
cr = factories.CourseRunFactory()
factories.SeatFactory(
course_run=cr,
type=credit_type,
price=100,
credit_provider=None,
credit_hours=None,
)
payload = {
"seats": [
{"type": credit_type.slug, "price": 110, "credit_provider": "NewProv", "credit_hours": 7}
]
}
request = make_request()
serializer = CourseRunSerializer(instance=cr, data=payload, partial=True, context={'request': request})
assert serializer.is_valid(), serializer.errors
updated = serializer.save()
updated_seat = updated.seats.filter(type=credit_type).first()
assert updated_seat.credit_provider == "NewProv"
assert updated_seat.credit_hours == 7
assert str(updated_seat.price) == "110.00"


class CourseRunWithProgramsSerializerTests(TestCase):
def setUp(self):
Expand Down Expand Up @@ -1916,6 +2012,35 @@ def test_price_validation_errors(self, price):

assert serializer.errors['price']

@pytest.mark.django_db
def test_seat_serializer_includes_credit_fields(self):
"""Ensure that SeatSerializer outputs credit fields for credit seats."""
credit_type = factories.SeatTypeFactory.credit()
seat = factories.SeatFactory(
type=credit_type,
price=200,
credit_provider="asu",
credit_hours=3,
)
serialized = SeatSerializer(seat).data
assert serialized["credit_provider"] == "asu"
assert serialized["credit_hours"] == 3
assert "upgrade_deadline" in serialized

@pytest.mark.django_db
def test_seat_serializer_hides_credit_fields_for_non_credit_seat(self):
"""Non-credit seats should NOT expose credit fields."""
verified_type = factories.SeatTypeFactory.verified()
seat = factories.SeatFactory(
type=verified_type,
price=100,
credit_provider="fake_provider",
credit_hours=2,
)
serialized = SeatSerializer(seat).data
assert "credit_provider" not in serialized or serialized["credit_provider"] is None
assert "credit_hours" not in serialized or serialized["credit_hours"] is None


class MinimalPersonSerializerTests(TestCase):
def setUp(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.23 on 2025-10-11 15:20

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('course_metadata', '0356_add_course_editor_update_bulk_operation'),
]

operations = [
migrations.AlterField(
model_name='historicalseat',
name='credit_hours',
field=models.IntegerField(blank=True, help_text='Number of credit hours awarded for this seat.', null=True),
),
migrations.AlterField(
model_name='historicalseat',
name='credit_provider',
field=models.CharField(blank=True, help_text='The provider granting academic credit for this seat, if applicable.', max_length=255, null=True),
),
migrations.AlterField(
model_name='seat',
name='credit_hours',
field=models.IntegerField(blank=True, help_text='Number of credit hours awarded for this seat.', null=True),
),
migrations.AlterField(
model_name='seat',
name='credit_provider',
field=models.CharField(blank=True, help_text='The provider granting academic credit for this seat, if applicable.', max_length=255, null=True),
),
]
Loading
Loading