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
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.models import (
Course, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension, OrganizationUserRole,
PublisherUser, Seat, User
Course, CourseMode, CourseRun, CourseRunState, CourseState, CourseUserRole, OrganizationExtension,
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.validators import validate_text_count
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):
def label_from_instance(self, obj):
......@@ -143,6 +151,9 @@ class CourseForm(BaseForm):
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:
model = Course
widgets = {
......@@ -153,7 +164,7 @@ class CourseForm(BaseForm):
'expected_learnings', 'primary_subject', 'secondary_subject',
'tertiary_subject', 'prerequisites', 'image', 'team_admin',
'level_type', 'organization', 'is_seo_review', 'syllabus',
'learner_testimonial', 'faq', 'video_link',
'learner_testimonial', 'faq', 'video_link', 'type', 'price',
)
def __init__(self, *args, **kwargs):
......@@ -175,6 +186,18 @@ class CourseForm(BaseForm):
if user and not is_internal_user(user):
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):
"""
Convert all named and numeric character references in the string
......@@ -197,12 +220,19 @@ class CourseForm(BaseForm):
organization = cleaned_data.get('organization')
title = cleaned_data.get('title')
number = cleaned_data.get('number')
course_type = cleaned_data.get('type')
price = cleaned_data.get('price')
instance = getattr(self, 'instance', None)
if not instance.pk:
if Course.objects.filter(title=title, organizations__in=[organization]).exists():
raise ValidationError({'title': _('This course title already exists')})
if Course.objects.filter(number=number, organizations__in=[organization]).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
......@@ -368,15 +398,7 @@ class SeatForm(BaseForm):
self.fields['type'].widget.attrs = {'class': field_classes}
TYPE_CHOICES = [
('', _('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'))
type = forms.ChoiceField(choices=SEAT_TYPE_CHOICES, required=False, label=_('Enrollment Track'))
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):
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.
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.upgrade_deadline = None
self.reset_credit_to_default(seat)
if seat.type == Seat.VERIFIED:
if seat.type == CourseMode.VERIFIED:
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
self.reset_credit_to_default(seat)
......@@ -408,14 +430,14 @@ class SeatForm(BaseForm):
if waffle.switch_is_active('publisher_create_audit_seats_for_verified_course_runs'):
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
if seat.type in (Seat.CREDIT, Seat.VERIFIED,):
if seat.type in (CourseMode.CREDIT, CourseMode.VERIFIED,):
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)
elif seat.type != Seat.AUDIT:
elif seat.type != CourseMode.AUDIT:
# Ensure that professional course runs do NOT have an audit seat
count = audit_seats.count()
audit_seats.delete()
......@@ -428,10 +450,10 @@ class SeatForm(BaseForm):
credit_price = self.cleaned_data.get('credit_price')
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.'))
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.'))
return self.cleaned_data
......
......@@ -31,7 +31,34 @@ from course_discovery.apps.publisher.validators import ImageMultiSizeValidator
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):
......@@ -90,6 +117,9 @@ class Course(TimeStampedModel, ChangedByMixin):
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):
return self.title
......@@ -102,6 +132,16 @@ class Course(TimeStampedModel, ChangedByMixin):
('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):
""" Returns the list of users emails with enable email notifications
against a course. By default if attribute value does not exists
......@@ -412,7 +452,7 @@ class CourseRun(TimeStampedModel, ChangedByMixin):
"""
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
def get_absolute_url(self):
......@@ -420,28 +460,6 @@ class CourseRun(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')
type = models.CharField(max_length=63, choices=SEAT_TYPE_CHOICES, verbose_name='Seat type')
price = models.DecimalField(**PRICE_FIELD_CONFIG)
......@@ -462,9 +480,9 @@ class Seat(TimeStampedModel, ChangedByMixin):
Check that seat is valid or not.
"""
return (
self.type == self.AUDIT or
(self.type in [self.VERIFIED, self.PROFESSIONAL] and self.price > 0) or
(self.type == self.CREDIT and self.credit_price > 0 and self.price > 0)
self.type == CourseMode.AUDIT or
(self.type in [CourseMode.VERIFIED, CourseMode.PROFESSIONAL] and self.price > 0) or
(self.type == CourseMode.CREDIT and self.credit_price > 0 and self.price > 0)
)
@property
......
......@@ -118,7 +118,7 @@
</ul>
</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>
{{ course_form.number }}
{% if course_form.number.errors %}
......@@ -128,6 +128,32 @@
{% endif %}
</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>
</div>
</div>
......
......@@ -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.forms import (AdminImportCourseForm, CourseForm, CourseRunForm, CourseSearchForm,
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)
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)
......@@ -726,7 +726,7 @@ class CourseRunEditView(mixins.LoginRequiredMixin, mixins.PublisherPermissionMix
context['run_form'] = self.run_form(
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:
context['seat_form'] = self.seat_form(instance=course_run_paid_seat)
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