Commit 0ff73820 by Michael Terry

Be entitlement-aware for new courses

When creating a new course in publisher, require type and price
up front rather than waiting until creating a course run.

LEARNER-3767
parent df77af18
...@@ -17,14 +17,22 @@ from course_discovery.apps.ietf_language_tags.models import LanguageTag ...@@ -17,14 +17,22 @@ from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.choices import CourseRunStateChoices, PublisherUserRole from course_discovery.apps.publisher.choices import CourseRunStateChoices, PublisherUserRole
from course_discovery.apps.publisher.mixins import LanguageModelSelect2Multiple, get_user_organizations from course_discovery.apps.publisher.mixins import LanguageModelSelect2Multiple, get_user_organizations
from course_discovery.apps.publisher.models import ( from course_discovery.apps.publisher.models import (
Course, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension, OrganizationUserRole, Course, CourseMode, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension,
PublisherUser, Seat, User OrganizationUserRole, PublisherUser, Seat, User
) )
from course_discovery.apps.publisher.utils import VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY, is_internal_user from course_discovery.apps.publisher.utils import VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY, is_internal_user
from course_discovery.apps.publisher.validators import validate_text_count from course_discovery.apps.publisher.validators import validate_text_count
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SEAT_TYPE_CHOICES = [
('', _('Choose enrollment track')),
(CourseMode.AUDIT, _('Audit only')),
(CourseMode.VERIFIED, _('Verified')),
(CourseMode.PROFESSIONAL, _('Professional education')),
(CourseMode.CREDIT, _('Credit')),
]
class UserModelChoiceField(forms.ModelChoiceField): class UserModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
...@@ -143,6 +151,9 @@ class CourseForm(BaseForm): ...@@ -143,6 +151,9 @@ class CourseForm(BaseForm):
add_new_run = forms.BooleanField(required=False) add_new_run = forms.BooleanField(required=False)
type = forms.ChoiceField(choices=SEAT_TYPE_CHOICES, required=True, label=_('Enrollment Track'))
price = forms.DecimalField(max_digits=6, decimal_places=2, required=False, initial=0.00)
class Meta: class Meta:
model = Course model = Course
widgets = { widgets = {
...@@ -153,7 +164,7 @@ class CourseForm(BaseForm): ...@@ -153,7 +164,7 @@ class CourseForm(BaseForm):
'expected_learnings', 'primary_subject', 'secondary_subject', 'expected_learnings', 'primary_subject', 'secondary_subject',
'tertiary_subject', 'prerequisites', 'image', 'team_admin', 'tertiary_subject', 'prerequisites', 'image', 'team_admin',
'level_type', 'organization', 'is_seo_review', 'syllabus', 'level_type', 'organization', 'is_seo_review', 'syllabus',
'learner_testimonial', 'faq', 'video_link', 'learner_testimonial', 'faq', 'video_link', 'type', 'price',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
...@@ -175,6 +186,18 @@ class CourseForm(BaseForm): ...@@ -175,6 +186,18 @@ class CourseForm(BaseForm):
if user and not is_internal_user(user): if user and not is_internal_user(user):
self.fields['video_link'].widget = forms.HiddenInput() self.fields['video_link'].widget = forms.HiddenInput()
def save(self, commit=True):
# When course is saved, make sure its prices and other fields are updated accordingly.
course = super(CourseForm, self).save(commit=False)
if course.type in CourseMode.FREE_MODES:
course.price = 0.00
if commit:
course.save()
return course
def clean_title(self): def clean_title(self):
""" """
Convert all named and numeric character references in the string Convert all named and numeric character references in the string
...@@ -197,12 +220,19 @@ class CourseForm(BaseForm): ...@@ -197,12 +220,19 @@ class CourseForm(BaseForm):
organization = cleaned_data.get('organization') organization = cleaned_data.get('organization')
title = cleaned_data.get('title') title = cleaned_data.get('title')
number = cleaned_data.get('number') number = cleaned_data.get('number')
course_type = cleaned_data.get('type')
price = cleaned_data.get('price')
instance = getattr(self, 'instance', None) instance = getattr(self, 'instance', None)
if not instance.pk: if not instance.pk:
if Course.objects.filter(title=title, organizations__in=[organization]).exists(): if Course.objects.filter(title=title, organizations__in=[organization]).exists():
raise ValidationError({'title': _('This course title already exists')}) raise ValidationError({'title': _('This course title already exists')})
if Course.objects.filter(number=number, organizations__in=[organization]).exists(): if Course.objects.filter(number=number, organizations__in=[organization]).exists():
raise ValidationError({'number': _('This course number already exists')}) raise ValidationError({'number': _('This course number already exists')})
if course_type in CourseMode.PAID_MODES and (price is None or price <= 0):
self.add_error('price', _('Only audit seat can be without price.'))
return cleaned_data return cleaned_data
...@@ -368,15 +398,7 @@ class SeatForm(BaseForm): ...@@ -368,15 +398,7 @@ class SeatForm(BaseForm):
self.fields['type'].widget.attrs = {'class': field_classes} self.fields['type'].widget.attrs = {'class': field_classes}
TYPE_CHOICES = [ type = forms.ChoiceField(choices=SEAT_TYPE_CHOICES, required=False, label=_('Enrollment Track'))
('', _('Choose enrollment track')),
(Seat.AUDIT, _('Audit only')),
(Seat.VERIFIED, _('Verified')),
(Seat.PROFESSIONAL, _('Professional education')),
(Seat.CREDIT, _('Credit')),
]
type = forms.ChoiceField(choices=TYPE_CHOICES, required=False, label=_('Enrollment Track'))
price = forms.DecimalField(max_digits=6, decimal_places=2, required=False, initial=0.00) price = forms.DecimalField(max_digits=6, decimal_places=2, required=False, initial=0.00)
credit_price = forms.DecimalField(max_digits=6, decimal_places=2, required=False, initial=0.00) credit_price = forms.DecimalField(max_digits=6, decimal_places=2, required=False, initial=0.00)
...@@ -387,13 +409,13 @@ class SeatForm(BaseForm): ...@@ -387,13 +409,13 @@ class SeatForm(BaseForm):
def save(self, commit=True, course_run=None, changed_by=None): # pylint: disable=arguments-differ def save(self, commit=True, course_run=None, changed_by=None): # pylint: disable=arguments-differ
# When seat is save make sure its prices and others fields updated accordingly. # When seat is save make sure its prices and others fields updated accordingly.
seat = super(SeatForm, self).save(commit=False) seat = super(SeatForm, self).save(commit=False)
if seat.type in [Seat.HONOR, Seat.AUDIT]: if seat.type in CourseMode.FREE_MODES:
seat.price = 0.00 seat.price = 0.00
seat.upgrade_deadline = None seat.upgrade_deadline = None
self.reset_credit_to_default(seat) self.reset_credit_to_default(seat)
if seat.type == Seat.VERIFIED: if seat.type == CourseMode.VERIFIED:
self.reset_credit_to_default(seat) self.reset_credit_to_default(seat)
if seat.type in [Seat.PROFESSIONAL, Seat.NO_ID_PROFESSIONAL]: if seat.type in [CourseMode.PROFESSIONAL, CourseMode.NO_ID_PROFESSIONAL]:
seat.upgrade_deadline = None seat.upgrade_deadline = None
self.reset_credit_to_default(seat) self.reset_credit_to_default(seat)
...@@ -408,14 +430,14 @@ class SeatForm(BaseForm): ...@@ -408,14 +430,14 @@ class SeatForm(BaseForm):
if waffle.switch_is_active('publisher_create_audit_seats_for_verified_course_runs'): if waffle.switch_is_active('publisher_create_audit_seats_for_verified_course_runs'):
course_run = seat.course_run course_run = seat.course_run
audit_seats = course_run.seats.filter(type=Seat.AUDIT) audit_seats = course_run.seats.filter(type=CourseMode.AUDIT)
# Ensure that course runs with a verified seat always have an audit seat # Ensure that course runs with a verified seat always have an audit seat
if seat.type in (Seat.CREDIT, Seat.VERIFIED,): if seat.type in (CourseMode.CREDIT, CourseMode.VERIFIED,):
if not audit_seats.exists(): if not audit_seats.exists():
course_run.seats.create(type=Seat.AUDIT, price=0, upgrade_deadline=None) course_run.seats.create(type=CourseMode.AUDIT, price=0, upgrade_deadline=None)
logger.info('Created audit seat for course run [%d]', course_run.id) logger.info('Created audit seat for course run [%d]', course_run.id)
elif seat.type != Seat.AUDIT: elif seat.type != CourseMode.AUDIT:
# Ensure that professional course runs do NOT have an audit seat # Ensure that professional course runs do NOT have an audit seat
count = audit_seats.count() count = audit_seats.count()
audit_seats.delete() audit_seats.delete()
...@@ -428,10 +450,10 @@ class SeatForm(BaseForm): ...@@ -428,10 +450,10 @@ class SeatForm(BaseForm):
credit_price = self.cleaned_data.get('credit_price') credit_price = self.cleaned_data.get('credit_price')
seat_type = self.cleaned_data.get('type') seat_type = self.cleaned_data.get('type')
if seat_type in [Seat.PROFESSIONAL, Seat.VERIFIED, Seat.CREDIT] and not price: if seat_type in CourseMode.PAID_MODES and not price:
self.add_error('price', _('Only audit seat can be without price.')) self.add_error('price', _('Only audit seat can be without price.'))
if seat_type == Seat.CREDIT and not credit_price: if seat_type == CourseMode.CREDIT and not credit_price:
self.add_error('credit_price', _('Only audit seat can be without price.')) self.add_error('credit_price', _('Only audit seat can be without price.'))
return self.cleaned_data return self.cleaned_data
......
...@@ -31,7 +31,34 @@ from course_discovery.apps.publisher.validators import ImageMultiSizeValidator ...@@ -31,7 +31,34 @@ from course_discovery.apps.publisher.validators import ImageMultiSizeValidator
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PAID_SEATS = ['verified', 'professional']
class CourseMode:
HONOR = 'honor'
AUDIT = 'audit'
VERIFIED = 'verified'
PROFESSIONAL = 'professional'
NO_ID_PROFESSIONAL = 'no-id-professional'
CREDIT = 'credit'
FREE_MODES = frozenset([HONOR, AUDIT])
PAID_MODES = frozenset([VERIFIED, PROFESSIONAL, NO_ID_PROFESSIONAL, CREDIT])
SEAT_TYPE_CHOICES = (
(CourseMode.HONOR, _('Honor')),
(CourseMode.AUDIT, _('Audit')),
(CourseMode.VERIFIED, _('Verified')),
(CourseMode.PROFESSIONAL, _('Professional (with ID verification)')),
(CourseMode.NO_ID_PROFESSIONAL, _('Professional (no ID verification)')),
(CourseMode.CREDIT, _('Credit')),
)
PRICE_FIELD_CONFIG = {
'decimal_places': 2,
'max_digits': 10,
'null': False,
'default': 0.00,
}
class ChangedByMixin(models.Model): class ChangedByMixin(models.Model):
...@@ -90,6 +117,9 @@ class Course(TimeStampedModel, ChangedByMixin): ...@@ -90,6 +117,9 @@ class Course(TimeStampedModel, ChangedByMixin):
history = HistoricalRecords() history = HistoricalRecords()
type = models.CharField(max_length=63, choices=SEAT_TYPE_CHOICES, verbose_name=_('Course type'))
price = models.DecimalField(**PRICE_FIELD_CONFIG)
def __str__(self): def __str__(self):
return self.title return self.title
...@@ -102,6 +132,16 @@ class Course(TimeStampedModel, ChangedByMixin): ...@@ -102,6 +132,16 @@ class Course(TimeStampedModel, ChangedByMixin):
('view_course', 'Can view course'), ('view_course', 'Can view course'),
) )
@property
def is_valid_course(self):
"""
Check that course is valid or not.
"""
return (
self.type == CourseMode.AUDIT or
(self.type in CourseMode.PAID_MODES and self.price > 0)
)
def get_course_users_emails(self): def get_course_users_emails(self):
""" Returns the list of users emails with enable email notifications """ Returns the list of users emails with enable email notifications
against a course. By default if attribute value does not exists against a course. By default if attribute value does not exists
...@@ -412,7 +452,7 @@ class CourseRun(TimeStampedModel, ChangedByMixin): ...@@ -412,7 +452,7 @@ class CourseRun(TimeStampedModel, ChangedByMixin):
""" """
Validate course-run has a valid seats. Validate course-run has a valid seats.
""" """
seats = self.seats.filter(type__in=[Seat.AUDIT, Seat.VERIFIED, Seat.PROFESSIONAL, Seat.CREDIT]) seats = self.seats.filter(type__in=[CourseMode.AUDIT, CourseMode.VERIFIED, CourseMode.PROFESSIONAL, CourseMode.CREDIT])
return all([seat.is_valid_seat for seat in seats]) if seats else False return all([seat.is_valid_seat for seat in seats]) if seats else False
def get_absolute_url(self): def get_absolute_url(self):
...@@ -420,28 +460,6 @@ class CourseRun(TimeStampedModel, ChangedByMixin): ...@@ -420,28 +460,6 @@ class CourseRun(TimeStampedModel, ChangedByMixin):
class Seat(TimeStampedModel, ChangedByMixin): class Seat(TimeStampedModel, ChangedByMixin):
HONOR = 'honor'
AUDIT = 'audit'
VERIFIED = 'verified'
PROFESSIONAL = 'professional'
NO_ID_PROFESSIONAL = 'no-id-professional'
CREDIT = 'credit'
SEAT_TYPE_CHOICES = (
(HONOR, _('Honor')),
(AUDIT, _('Audit')),
(VERIFIED, _('Verified')),
(PROFESSIONAL, _('Professional (with ID verification)')),
(NO_ID_PROFESSIONAL, _('Professional (no ID verification)')),
(CREDIT, _('Credit')),
)
PRICE_FIELD_CONFIG = {
'decimal_places': 2,
'max_digits': 10,
'null': False,
'default': 0.00,
}
course_run = models.ForeignKey(CourseRun, related_name='seats') course_run = models.ForeignKey(CourseRun, related_name='seats')
type = models.CharField(max_length=63, choices=SEAT_TYPE_CHOICES, verbose_name='Seat type') type = models.CharField(max_length=63, choices=SEAT_TYPE_CHOICES, verbose_name='Seat type')
price = models.DecimalField(**PRICE_FIELD_CONFIG) price = models.DecimalField(**PRICE_FIELD_CONFIG)
...@@ -462,9 +480,9 @@ class Seat(TimeStampedModel, ChangedByMixin): ...@@ -462,9 +480,9 @@ class Seat(TimeStampedModel, ChangedByMixin):
Check that seat is valid or not. Check that seat is valid or not.
""" """
return ( return (
self.type == self.AUDIT or self.type == CourseMode.AUDIT or
(self.type in [self.VERIFIED, self.PROFESSIONAL] and self.price > 0) or (self.type in [CourseMode.VERIFIED, CourseMode.PROFESSIONAL] and self.price > 0) or
(self.type == self.CREDIT and self.credit_price > 0 and self.price > 0) (self.type == CourseMode.CREDIT and self.credit_price > 0 and self.price > 0)
) )
@property @property
......
...@@ -118,7 +118,7 @@ ...@@ -118,7 +118,7 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="col col-6"> <div class="col col-6">
<label class="field-label ">{{ course_form.number.label_tag }} <span class="required">*</span></label> <label class="field-label ">{{ course_form.number.label_tag }} <span class="required">*</span></label>
{{ course_form.number }} {{ course_form.number }}
{% if course_form.number.errors %} {% if course_form.number.errors %}
...@@ -128,6 +128,32 @@ ...@@ -128,6 +128,32 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="field-title">{% trans "CERTIFICATE TYPE AND PRICE" %} <span class="required float-right">* Required</span></div>
<div class="row">
<div class="col col-6 help-text">
{% trans "If the course offers a verified or professional education certificate, select the certificate type and enter the price for the certificate." %}
</div>
<div class="col col-6">
<div class="row">
<div class="col col-6">
<label class="field-label ">{{ course_form.type.label_tag }} <span class="required">*</span></label>
{{ course_form.type }}
</div>
<div id="SeatPriceBlock" class="col col-6 {% if course_form.type.value == 'audit' or not course_form.type.value %}hidden{% endif %}">
<label class="field-label ">{{ course_form.price.label_tag }} <span class="required">*</span></label>
{{ course_form.price }}
</div>
</div>
{% if course_form.price.errors %}
<div class="field-message has-error">
<span class="field-message-content">
{{ course_form.price.errors|escape }}
</span>
</div>
{% endif %}
</div>
</div>
</fieldset> </fieldset>
</div> </div>
</div> </div>
......
...@@ -31,7 +31,7 @@ from course_discovery.apps.publisher.dataloader.create_courses import process_co ...@@ -31,7 +31,7 @@ from course_discovery.apps.publisher.dataloader.create_courses import process_co
from course_discovery.apps.publisher.emails import send_email_for_published_course_run_editing from course_discovery.apps.publisher.emails import send_email_for_published_course_run_editing
from course_discovery.apps.publisher.forms import (AdminImportCourseForm, CourseForm, CourseRunForm, CourseSearchForm, from course_discovery.apps.publisher.forms import (AdminImportCourseForm, CourseForm, CourseRunForm, CourseSearchForm,
SeatForm) SeatForm)
from course_discovery.apps.publisher.models import (PAID_SEATS, Course, CourseRun, CourseRunState, CourseState, from course_discovery.apps.publisher.models import (Course, CourseMode, CourseRun, CourseRunState, CourseState,
CourseUserRole, OrganizationExtension, Seat, UserAttributes) CourseUserRole, OrganizationExtension, Seat, UserAttributes)
from course_discovery.apps.publisher.utils import (get_internal_users, has_role_for_course, is_internal_user, from course_discovery.apps.publisher.utils import (get_internal_users, has_role_for_course, is_internal_user,
is_project_coordinator_user, is_publisher_admin, make_bread_crumbs) is_project_coordinator_user, is_publisher_admin, make_bread_crumbs)
...@@ -726,7 +726,7 @@ class CourseRunEditView(mixins.LoginRequiredMixin, mixins.PublisherPermissionMix ...@@ -726,7 +726,7 @@ class CourseRunEditView(mixins.LoginRequiredMixin, mixins.PublisherPermissionMix
context['run_form'] = self.run_form( context['run_form'] = self.run_form(
instance=course_run, is_project_coordinator=context.get('is_project_coordinator') instance=course_run, is_project_coordinator=context.get('is_project_coordinator')
) )
course_run_paid_seat = course_run.seats.filter(type__in=PAID_SEATS).first() course_run_paid_seat = course_run.seats.filter(type__in=CourseMode.PAID_MODES).first()
if course_run_paid_seat: if course_run_paid_seat:
context['seat_form'] = self.seat_form(instance=course_run_paid_seat) context['seat_form'] = self.seat_form(instance=course_run_paid_seat)
else: else:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment