Commit 386bd8c5 by Clinton Blackburn Committed by GitHub

Merge pull request #290 from edx/clintonb/search-published

Updated search API endpoint to return only published items
parents a8e017dc cf4605e5
...@@ -45,7 +45,7 @@ COURSE_RUN_FACET_FIELD_QUERIES = { ...@@ -45,7 +45,7 @@ COURSE_RUN_FACET_FIELD_QUERIES = {
COURSE_RUN_SEARCH_FIELDS = ( COURSE_RUN_SEARCH_FIELDS = (
'text', 'key', 'title', 'short_description', 'full_description', 'start', 'end', 'enrollment_start', 'text', 'key', 'title', 'short_description', 'full_description', 'start', 'end', 'enrollment_start',
'enrollment_end', 'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'org', 'enrollment_end', 'pacing_type', 'language', 'transcript_languages', 'marketing_url', 'content_type', 'org',
'number', 'seat_types', 'image_url', 'type', 'level_type', 'availability', 'number', 'seat_types', 'image_url', 'type', 'level_type', 'availability', 'published',
) )
PROGRAM_FACET_FIELD_OPTIONS = { PROGRAM_FACET_FIELD_OPTIONS = {
...@@ -57,6 +57,7 @@ PROGRAM_FACET_FIELD_OPTIONS = { ...@@ -57,6 +57,7 @@ PROGRAM_FACET_FIELD_OPTIONS = {
BASE_PROGRAM_FIELDS = ( BASE_PROGRAM_FIELDS = (
'text', 'uuid', 'title', 'subtitle', 'type', 'marketing_url', 'content_type', 'status', 'card_image_url', 'text', 'uuid', 'title', 'subtitle', 'type', 'marketing_url', 'content_type', 'status', 'card_image_url',
'published',
) )
PROGRAM_SEARCH_FIELDS = BASE_PROGRAM_FIELDS + ('authoring_organizations',) PROGRAM_SEARCH_FIELDS = BASE_PROGRAM_FIELDS + ('authoring_organizations',)
......
...@@ -606,6 +606,7 @@ class CourseRunSearchSerializerTests(TestCase): ...@@ -606,6 +606,7 @@ class CourseRunSearchSerializerTests(TestCase):
'type': course_run.type, 'type': course_run.type,
'level_type': course_run.level_type.name, 'level_type': course_run.level_type.name,
'availability': course_run.availability, 'availability': course_run.availability,
'published': course_run.status == CourseRun.Status.Published,
} }
self.assertDictEqual(serializer.data, expected) self.assertDictEqual(serializer.data, expected)
...@@ -648,6 +649,7 @@ class ProgramSearchSerializerTests(TestCase): ...@@ -648,6 +649,7 @@ class ProgramSearchSerializerTests(TestCase):
'content_type': 'program', 'content_type': 'program',
'card_image_url': program.card_image_url, 'card_image_url': program.card_image_url,
'status': program.status, 'status': program.status,
'published': program.status == Program.Status.Active,
} }
self.assertDictEqual(serializer.data, expected) self.assertDictEqual(serializer.data, expected)
...@@ -668,5 +670,6 @@ class ProgramSearchSerializerTests(TestCase): ...@@ -668,5 +670,6 @@ class ProgramSearchSerializerTests(TestCase):
'content_type': 'program', 'content_type': 'program',
'card_image_url': program.card_image_url, 'card_image_url': program.card_image_url,
'status': program.status, 'status': program.status,
'published': program.status == Program.Status.Active,
} }
self.assertDictEqual(serializer.data, expected) self.assertDictEqual(serializer.data, expected)
...@@ -7,24 +7,36 @@ from django.core.urlresolvers import reverse ...@@ -7,24 +7,36 @@ from django.core.urlresolvers import reverse
from haystack.query import SearchQuerySet from haystack.query import SearchQuerySet
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from course_discovery.apps.api.serializers import CourseRunSearchSerializer from course_discovery.apps.api.serializers import CourseRunSearchSerializer, ProgramSearchSerializer
from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD from course_discovery.apps.core.tests.factories import UserFactory, USER_PASSWORD
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
from course_discovery.apps.course_metadata.models import CourseRun from course_discovery.apps.course_metadata.models import CourseRun, Program
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory
@ddt.ddt class SerializationMixin:
class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase): def serialize_course_run(self, course_run):
""" Tests for CourseRunSearchViewSet. """ result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
faceted_path = reverse('api:v1:search-course_runs-facets') return CourseRunSearchSerializer(result).data
list_path = reverse('api:v1:search-course_runs-list')
def serialize_program(self, program):
result = SearchQuerySet().models(Program).filter(uuid=program.uuid)[0]
return ProgramSearchSerializer(result).data
class LoginMixin:
def setUp(self): def setUp(self):
super(CourseRunSearchViewSetTests, self).setUp() super(LoginMixin, self).setUp()
self.user = UserFactory() self.user = UserFactory()
self.client.login(username=self.user.username, password=USER_PASSWORD) self.client.login(username=self.user.username, password=USER_PASSWORD)
@ddt.ddt
class CourseRunSearchViewSetTests(SerializationMixin, LoginMixin, ElasticsearchTestMixin, APITestCase):
""" Tests for CourseRunSearchViewSet. """
faceted_path = reverse('api:v1:search-course_runs-facets')
list_path = reverse('api:v1:search-course_runs-list')
def get_search_response(self, query=None, faceted=False): def get_search_response(self, query=None, faceted=False):
qs = '' qs = ''
...@@ -35,10 +47,6 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase): ...@@ -35,10 +47,6 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
url = '{path}?{qs}'.format(path=path, qs=qs) url = '{path}?{qs}'.format(path=path, qs=qs)
return self.client.get(url) return self.client.get(url)
def serialize_course_run(self, course_run):
result = SearchQuerySet().models(CourseRun).filter(key=course_run.key)[0]
return CourseRunSearchSerializer(result).data
@ddt.data(True, False) @ddt.data(True, False)
def test_authentication(self, faceted): def test_authentication(self, faceted):
""" Verify the endpoint requires authentication. """ """ Verify the endpoint requires authentication. """
...@@ -65,7 +73,7 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase): ...@@ -65,7 +73,7 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
""" Asserts the search functionality returns results for a generated query. """ """ Asserts the search functionality returns results for a generated query. """
# Generate data that should be indexed and returned by the query # Generate data that should be indexed and returned by the query
course_run = CourseRunFactory(course__title='Software Testing') course_run = CourseRunFactory(course__title='Software Testing', status=CourseRun.Status.Published)
response = self.get_search_response('software', faceted=faceted) response = self.get_search_response('software', faceted=faceted)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -101,10 +109,14 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase): ...@@ -101,10 +109,14 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
def test_availability_faceting(self): def test_availability_faceting(self):
""" Verify the endpoint returns availability facets with the results. """ """ Verify the endpoint returns availability facets with the results. """
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
archived = CourseRunFactory(start=now - datetime.timedelta(weeks=2), end=now - datetime.timedelta(weeks=1)) archived = CourseRunFactory(start=now - datetime.timedelta(weeks=2), end=now - datetime.timedelta(weeks=1),
current = CourseRunFactory(start=now - datetime.timedelta(weeks=2), end=now + datetime.timedelta(weeks=1)) status=CourseRun.Status.Published)
starting_soon = CourseRunFactory(start=now + datetime.timedelta(days=10), end=now + datetime.timedelta(days=90)) current = CourseRunFactory(start=now - datetime.timedelta(weeks=2), end=now + datetime.timedelta(weeks=1),
upcoming = CourseRunFactory(start=now + datetime.timedelta(days=61), end=now + datetime.timedelta(days=90)) status=CourseRun.Status.Published)
starting_soon = CourseRunFactory(start=now + datetime.timedelta(days=10), end=now + datetime.timedelta(days=90),
status=CourseRun.Status.Published)
upcoming = CourseRunFactory(start=now + datetime.timedelta(days=61), end=now + datetime.timedelta(days=90),
status=CourseRun.Status.Published)
response = self.get_search_response(faceted=True) response = self.get_search_response(faceted=True)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -148,3 +160,31 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase): ...@@ -148,3 +160,31 @@ class CourseRunSearchViewSetTests(ElasticsearchTestMixin, APITestCase):
}, },
} }
self.assertDictContainsSubset(expected, response_data['queries']) self.assertDictContainsSubset(expected, response_data['queries'])
class AggregateSearchViewSet(SerializationMixin, LoginMixin, ElasticsearchTestMixin, APITestCase):
path = reverse('api:v1:search-all-facets')
def get_search_response(self, query=None):
qs = ''
if query:
qs = urllib.parse.urlencode({'q': query})
url = '{path}?{qs}'.format(path=self.path, qs=qs)
return self.client.get(url)
def test_results_only_include_published_objects(self):
""" Verify the search results only include items with status set to 'Published'. """
# These items should NOT be in the results
CourseRunFactory(status=CourseRun.Status.Unpublished)
ProgramFactory(status=Program.Status.Unpublished)
course_run = CourseRunFactory(status=CourseRun.Status.Published)
program = ProgramFactory(status=Program.Status.Active)
response = self.get_search_response()
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertListEqual(response_data['objects']['results'],
[self.serialize_course_run(course_run), self.serialize_program(program)])
...@@ -328,7 +328,7 @@ class CourseRunViewSet(viewsets.ReadOnlyModelViewSet): ...@@ -328,7 +328,7 @@ class CourseRunViewSet(viewsets.ReadOnlyModelViewSet):
if query and course_run_ids: if query and course_run_ids:
course_run_ids = course_run_ids.split(',') course_run_ids = course_run_ids.split(',')
course_runs = CourseRun.search(query).filter(partner=partner.short_code).filter(key__in=course_run_ids).\ course_runs = CourseRun.search(query).filter(partner=partner.short_code).filter(key__in=course_run_ids). \
values_list('key', flat=True) values_list('key', flat=True)
contains = {course_run_id: course_run_id in course_runs for course_run_id in course_run_ids} contains = {course_run_id: course_run_id in course_runs for course_run_id in course_run_ids}
...@@ -477,7 +477,7 @@ class BaseHaystackViewSet(FacetMixin, HaystackViewSet): ...@@ -477,7 +477,7 @@ class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
""" """
return super(BaseHaystackViewSet, self).list(request, *args, **kwargs) return super(BaseHaystackViewSet, self).list(request, *args, **kwargs)
@list_route(methods=["get"], url_path="facets") @list_route(methods=['get'], url_path='facets')
def facets(self, request): def facets(self, request):
""" """
Returns faceted search results Returns faceted search results
...@@ -513,6 +513,9 @@ class BaseHaystackViewSet(FacetMixin, HaystackViewSet): ...@@ -513,6 +513,9 @@ class BaseHaystackViewSet(FacetMixin, HaystackViewSet):
facet_serializer_cls = self.get_facet_serializer_class() facet_serializer_cls = self.get_facet_serializer_class()
field_queries = getattr(facet_serializer_cls.Meta, 'field_queries', {}) field_queries = getattr(facet_serializer_cls.Meta, 'field_queries', {})
# Ensure we only return published items
queryset = queryset.filter(published=True)
for facet in self.request.query_params.getlist('selected_query_facets'): for facet in self.request.query_params.getlist('selected_query_facets'):
query = field_queries.get(facet) query = field_queries.get(facet)
......
...@@ -53,7 +53,8 @@ class CourseRunAdmin(admin.ModelAdmin): ...@@ -53,7 +53,8 @@ class CourseRunAdmin(admin.ModelAdmin):
list_display = ('uuid', 'key', 'title',) list_display = ('uuid', 'key', 'title',)
list_filter = ( list_filter = (
'course__partner', 'course__partner',
('language', admin.RelatedOnlyFieldListFilter,) ('language', admin.RelatedOnlyFieldListFilter,),
'status',
) )
ordering = ('key',) ordering = ('key',)
readonly_fields = ('uuid',) readonly_fields = ('uuid',)
...@@ -65,7 +66,7 @@ class ProgramAdmin(admin.ModelAdmin): ...@@ -65,7 +66,7 @@ class ProgramAdmin(admin.ModelAdmin):
form = ProgramAdminForm form = ProgramAdminForm
inlines = [FaqsInline, IndividualEndorsementInline, CorporateEndorsementsInline] inlines = [FaqsInline, IndividualEndorsementInline, CorporateEndorsementsInline]
list_display = ('id', 'uuid', 'title', 'category', 'type', 'partner', 'status',) list_display = ('id', 'uuid', 'title', 'category', 'type', 'partner', 'status',)
list_filter = ('partner', 'type',) list_filter = ('partner', 'type', 'status',)
ordering = ('uuid', 'title', 'status') ordering = ('uuid', 'title', 'status')
readonly_fields = ('uuid', 'custom_course_runs_display', 'excluded_course_runs',) readonly_fields = ('uuid', 'custom_course_runs_display', 'excluded_course_runs',)
search_fields = ('uuid', 'title', 'marketing_slug') search_fields = ('uuid', 'title', 'marketing_slug')
......
...@@ -135,6 +135,7 @@ class CoursesApiDataLoader(AbstractDataLoader): ...@@ -135,6 +135,7 @@ class CoursesApiDataLoader(AbstractDataLoader):
'title_override': body['name'], 'title_override': body['name'],
'short_description_override': body['short_description'], 'short_description_override': body['short_description'],
'video': self.get_courserun_video(body), 'video': self.get_courserun_video(body),
'status': CourseRun.Status.Published,
}) })
course_run, __ = course.course_runs.update_or_create(key__iexact=key, defaults=defaults) course_run, __ = course.course_runs.update_or_create(key__iexact=key, defaults=defaults)
...@@ -149,9 +150,9 @@ class CoursesApiDataLoader(AbstractDataLoader): ...@@ -149,9 +150,9 @@ class CoursesApiDataLoader(AbstractDataLoader):
pacing = pacing.lower() pacing = pacing.lower()
if pacing == 'instructor': if pacing == 'instructor':
return CourseRun.INSTRUCTOR_PACED return CourseRun.Pacing.Instructor
elif pacing == 'self': elif pacing == 'self':
return CourseRun.SELF_PACED return CourseRun.Pacing.Self
else: else:
return None return None
......
...@@ -370,6 +370,9 @@ class CourseMarketingSiteDataLoader(AbstractMarketingSiteDataLoader): ...@@ -370,6 +370,9 @@ class CourseMarketingSiteDataLoader(AbstractMarketingSiteDataLoader):
description = self.clean_html(description) description = self.clean_html(description)
return description return description
def get_course_run_status(self, data):
return CourseRun.Status.Published if bool(int(data['status'])) else CourseRun.Status.Unpublished
def get_level_type(self, name): def get_level_type(self, name):
level_type = None level_type = None
...@@ -397,6 +400,7 @@ class CourseMarketingSiteDataLoader(AbstractMarketingSiteDataLoader): ...@@ -397,6 +400,7 @@ class CourseMarketingSiteDataLoader(AbstractMarketingSiteDataLoader):
'language': language, 'language': language,
'slug': slug, 'slug': slug,
'card_image_url': self._get_nested_url(data.get('field_course_image_promoted')), 'card_image_url': self._get_nested_url(data.get('field_course_image_promoted')),
'status': self.get_course_run_status(data),
} }
try: try:
......
...@@ -158,6 +158,7 @@ class CoursesApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCas ...@@ -158,6 +158,7 @@ class CoursesApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCas
'title_override': body['name'], 'title_override': body['name'],
'short_description_override': self.loader.clean_string(body['short_description']), 'short_description_override': self.loader.clean_string(body['short_description']),
'video': self.loader.get_courserun_video(body), 'video': self.loader.get_courserun_video(body),
'status': CourseRun.Status.Published,
}) })
for field, value in expected_values.items(): for field, value in expected_values.items():
...@@ -214,10 +215,10 @@ class CoursesApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCas ...@@ -214,10 +215,10 @@ class CoursesApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCas
('', None), ('', None),
('foo', None), ('foo', None),
(None, None), (None, None),
('instructor', CourseRun.INSTRUCTOR_PACED), ('instructor', CourseRun.Pacing.Instructor),
('Instructor', CourseRun.INSTRUCTOR_PACED), ('Instructor', CourseRun.Pacing.Instructor),
('self', CourseRun.SELF_PACED), ('self', CourseRun.Pacing.Self),
('Self', CourseRun.SELF_PACED), ('Self', CourseRun.Pacing.Self),
) )
def test_get_pacing_type(self, pacing, expected_pacing_type): def test_get_pacing_type(self, pacing, expected_pacing_type):
""" Verify the method returns a pacing type corresponding to the API response's pacing field. """ """ Verify the method returns a pacing type corresponding to the API response's pacing field. """
......
...@@ -14,7 +14,8 @@ from course_discovery.apps.course_metadata.data_loaders.marketing_site import ( ...@@ -14,7 +14,8 @@ from course_discovery.apps.course_metadata.data_loaders.marketing_site import (
) )
from course_discovery.apps.course_metadata.data_loaders.tests import JSON, mock_data from course_discovery.apps.course_metadata.data_loaders.tests import JSON, mock_data
from course_discovery.apps.course_metadata.data_loaders.tests.mixins import DataLoaderTestMixin from course_discovery.apps.course_metadata.data_loaders.tests.mixins import DataLoaderTestMixin
from course_discovery.apps.course_metadata.models import Organization, Subject, Program, Video, Person, Course from course_discovery.apps.course_metadata.models import Organization, Subject, Program, Video, Person, Course, \
CourseRun
from course_discovery.apps.course_metadata.tests import factories from course_discovery.apps.course_metadata.tests import factories
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
...@@ -354,6 +355,15 @@ class CourseMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixi ...@@ -354,6 +355,15 @@ class CourseMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixi
name = 'Advanced' name = 'Advanced'
self.assertEqual(self.loader.get_level_type(name).name, name) self.assertEqual(self.loader.get_level_type(name).name, name)
@ddt.unpack
@ddt.data(
('0', CourseRun.Status.Unpublished),
('1', CourseRun.Status.Published),
)
def test_get_course_run_status(self, marketing_site_status, expected):
data = {'status': marketing_site_status}
self.assertEqual(self.loader.get_course_run_status(data), expected)
@ddt.data( @ddt.data(
{'field_course_body': {'value': 'Test'}}, {'field_course_body': {'value': 'Test'}},
{'field_course_description': {'value': 'Test'}}, {'field_course_description': {'value': 'Test'}},
...@@ -417,6 +427,7 @@ class CourseMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixi ...@@ -417,6 +427,7 @@ class CourseMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixi
'language': language, 'language': language,
'slug': data['url'].split('/')[-1], 'slug': data['url'].split('/')[-1],
'card_image_url': (data.get('field_course_image_promoted') or {}).get('url'), 'card_image_url': (data.get('field_course_image_promoted') or {}).get('url'),
'status': self.loader.get_course_run_status(data),
} }
for field, value in expected_values.items(): for field, value in expected_values.items():
......
...@@ -48,7 +48,7 @@ class ProgramAdminForm(forms.ModelForm): ...@@ -48,7 +48,7 @@ class ProgramAdminForm(forms.ModelForm):
def clean(self): def clean(self):
status = self.cleaned_data.get('status') status = self.cleaned_data.get('status')
banner_image = self.cleaned_data.get('banner_image') banner_image = self.cleaned_data.get('banner_image')
if status == Program.ProgramStatus.Active and not banner_image: if status == Program.Status.Active and not banner_image:
raise ValidationError(_('Status cannot be change to active without banner image.')) raise ValidationError(_('Status cannot be change to active without banner image.'))
return self.cleaned_data return self.cleaned_data
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import djchoices.choices
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0023_auto_20160826_1912'),
]
operations = [
migrations.AddField(
model_name='courserun',
name='status',
field=models.CharField(db_index=True, validators=[djchoices.choices.ChoicesValidator({'unpublished': 'Unpublished', 'published': 'Published'})], choices=[('published', 'Published'), ('unpublished', 'Unpublished')], max_length=255, default='unpublished'),
preserve_default=False,
),
migrations.AddField(
model_name='historicalcourserun',
name='status',
field=models.CharField(db_index=True, validators=[djchoices.choices.ChoicesValidator({'unpublished': 'Unpublished', 'published': 'Published'})], choices=[('published', 'Published'), ('unpublished', 'Unpublished')], max_length=255, default='unpublished'),
preserve_default=False,
),
migrations.AlterField(
model_name='courserun',
name='pacing_type',
field=models.CharField(choices=[('instructor_paced', 'Instructor-paced'), ('self_paced', 'Self-paced')], null=True, db_index=True, validators=[djchoices.choices.ChoicesValidator({'instructor_paced': 'Instructor-paced', 'self_paced': 'Self-paced'})], blank=True, max_length=255),
),
migrations.AlterField(
model_name='historicalcourserun',
name='pacing_type',
field=models.CharField(choices=[('instructor_paced', 'Instructor-paced'), ('self_paced', 'Self-paced')], null=True, db_index=True, validators=[djchoices.choices.ChoicesValidator({'instructor_paced': 'Instructor-paced', 'self_paced': 'Self-paced'})], blank=True, max_length=255),
),
migrations.AlterField(
model_name='program',
name='status',
field=models.CharField(db_index=True, help_text='The lifecycle status of this Program.', choices=[('unpublished', 'Unpublished'), ('active', 'Active'), ('retired', 'Retired'), ('deleted', 'Deleted')], max_length=24, validators=[djchoices.choices.ChoicesValidator({'unpublished': 'Unpublished', 'active': 'Active', 'deleted': 'Deleted', 'retired': 'Retired'})]),
),
]
...@@ -308,21 +308,23 @@ class Course(TimeStampedModel): ...@@ -308,21 +308,23 @@ class Course(TimeStampedModel):
class CourseRun(TimeStampedModel): class CourseRun(TimeStampedModel):
""" CourseRun model. """ """ CourseRun model. """
SELF_PACED = 'self_paced'
INSTRUCTOR_PACED = 'instructor_paced'
PACING_CHOICES = ( class Status(DjangoChoices):
# Translators: Self-paced refers to course runs that operate on the student's schedule. Published = ChoiceItem('published', _('Published'))
(SELF_PACED, _('Self-paced')), Unpublished = ChoiceItem('unpublished', _('Unpublished'))
class Pacing(DjangoChoices):
# Translators: Instructor-paced refers to course runs that operate on a schedule set by the instructor, # Translators: Instructor-paced refers to course runs that operate on a schedule set by the instructor,
# similar to a normal university course. # similar to a normal university course.
(INSTRUCTOR_PACED, _('Instructor-paced')), Instructor = ChoiceItem('instructor_paced', _('Instructor-paced'))
) # Translators: Self-paced refers to course runs that operate on the student's schedule.
Self = ChoiceItem('self_paced', _('Self-paced'))
uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID')) uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID'))
course = models.ForeignKey(Course, related_name='course_runs') course = models.ForeignKey(Course, related_name='course_runs')
key = models.CharField(max_length=255, unique=True) key = models.CharField(max_length=255, unique=True)
status = models.CharField(max_length=255, null=False, blank=False, db_index=True, choices=Status.choices,
validators=[Status.validator])
title_override = models.CharField( title_override = models.CharField(
max_length=255, default=None, null=True, blank=True, max_length=255, default=None, null=True, blank=True,
help_text=_( help_text=_(
...@@ -351,7 +353,8 @@ class CourseRun(TimeStampedModel): ...@@ -351,7 +353,8 @@ class CourseRun(TimeStampedModel):
help_text=_('Estimated maximum number of hours per week needed to complete a course run.')) help_text=_('Estimated maximum number of hours per week needed to complete a course run.'))
language = models.ForeignKey(LanguageTag, null=True, blank=True) language = models.ForeignKey(LanguageTag, null=True, blank=True)
transcript_languages = models.ManyToManyField(LanguageTag, blank=True, related_name='transcript_courses') transcript_languages = models.ManyToManyField(LanguageTag, blank=True, related_name='transcript_courses')
pacing_type = models.CharField(max_length=255, choices=PACING_CHOICES, db_index=True, null=True, blank=True) pacing_type = models.CharField(max_length=255, db_index=True, null=True, blank=True, choices=Pacing.choices,
validators=[Pacing.validator])
syllabus = models.ForeignKey(SyllabusItem, default=None, null=True, blank=True) syllabus = models.ForeignKey(SyllabusItem, default=None, null=True, blank=True)
card_image_url = models.URLField(null=True, blank=True) card_image_url = models.URLField(null=True, blank=True)
video = models.ForeignKey(Video, default=None, null=True, blank=True) video = models.ForeignKey(Video, default=None, null=True, blank=True)
...@@ -565,7 +568,7 @@ class ProgramType(TimeStampedModel): ...@@ -565,7 +568,7 @@ class ProgramType(TimeStampedModel):
class Program(TimeStampedModel): class Program(TimeStampedModel):
class ProgramStatus(DjangoChoices): class Status(DjangoChoices):
Unpublished = ChoiceItem('unpublished', _('Unpublished')) Unpublished = ChoiceItem('unpublished', _('Unpublished'))
Active = ChoiceItem('active', _('Active')) Active = ChoiceItem('active', _('Active'))
Retired = ChoiceItem('retired', _('Retired')) Retired = ChoiceItem('retired', _('Retired'))
...@@ -580,8 +583,8 @@ class Program(TimeStampedModel): ...@@ -580,8 +583,8 @@ class Program(TimeStampedModel):
category = models.CharField(help_text=_('The category / type of Program.'), max_length=32) category = models.CharField(help_text=_('The category / type of Program.'), max_length=32)
type = models.ForeignKey(ProgramType, null=True, blank=True) type = models.ForeignKey(ProgramType, null=True, blank=True)
status = models.CharField( status = models.CharField(
help_text=_('The lifecycle status of this Program.'), max_length=24, null=False, blank=False, help_text=_('The lifecycle status of this Program.'), max_length=24, null=False, blank=False, db_index=True,
choices=ProgramStatus.choices, validators=[ProgramStatus.validator] choices=Status.choices, validators=[Status.validator]
) )
marketing_slug = models.CharField( marketing_slug = models.CharField(
help_text=_('Slug used to generate links to the marketing site'), blank=True, max_length=255, db_index=True) help_text=_('Slug used to generate links to the marketing site'), blank=True, max_length=255, db_index=True)
......
...@@ -95,6 +95,7 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): ...@@ -95,6 +95,7 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
course_key = indexes.CharField(model_attr='course__key', stored=True) course_key = indexes.CharField(model_attr='course__key', stored=True)
org = indexes.CharField() org = indexes.CharField()
number = indexes.CharField() number = indexes.CharField()
status = indexes.CharField(model_attr='status', faceted=True)
start = indexes.DateTimeField(model_attr='start', null=True, faceted=True) start = indexes.DateTimeField(model_attr='start', null=True, faceted=True)
end = indexes.DateTimeField(model_attr='end', null=True) end = indexes.DateTimeField(model_attr='end', null=True)
enrollment_start = indexes.DateTimeField(model_attr='enrollment_start', null=True) enrollment_start = indexes.DateTimeField(model_attr='enrollment_start', null=True)
...@@ -111,6 +112,10 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable): ...@@ -111,6 +112,10 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
type = indexes.CharField(model_attr='type', null=True, faceted=True) type = indexes.CharField(model_attr='type', null=True, faceted=True)
image_url = indexes.CharField(model_attr='card_image_url', null=True) image_url = indexes.CharField(model_attr='card_image_url', null=True)
partner = indexes.CharField(model_attr='course__partner__short_code', null=True, faceted=True) partner = indexes.CharField(model_attr='course__partner__short_code', null=True, faceted=True)
published = indexes.BooleanField(null=False, faceted=True)
def prepare_published(self, obj):
return obj.status == CourseRun.Status.Published
def _prepare_language(self, language): def _prepare_language(self, language):
return language.macrolanguage return language.macrolanguage
...@@ -152,6 +157,10 @@ class ProgramIndex(BaseIndex, indexes.Indexable, OrganizationsMixin): ...@@ -152,6 +157,10 @@ class ProgramIndex(BaseIndex, indexes.Indexable, OrganizationsMixin):
partner = indexes.CharField(model_attr='partner__short_code', null=True, faceted=True) partner = indexes.CharField(model_attr='partner__short_code', null=True, faceted=True)
start = indexes.DateTimeField(model_attr='start', null=True, faceted=True) start = indexes.DateTimeField(model_attr='start', null=True, faceted=True)
seat_types = indexes.MultiValueField(model_attr='seat_types', null=True, faceted=True) seat_types = indexes.MultiValueField(model_attr='seat_types', null=True, faceted=True)
published = indexes.BooleanField(null=False, faceted=True)
def prepare_published(self, obj):
return obj.status == Program.Status.Active
def prepare_organizations(self, obj): def prepare_organizations(self, obj):
return self.prepare_authoring_organizations(obj) + self.prepare_credit_backing_organizations(obj) return self.prepare_authoring_organizations(obj) + self.prepare_credit_backing_organizations(obj)
......
...@@ -106,6 +106,7 @@ class CourseFactory(factory.DjangoModelFactory): ...@@ -106,6 +106,7 @@ class CourseFactory(factory.DjangoModelFactory):
class CourseRunFactory(factory.DjangoModelFactory): class CourseRunFactory(factory.DjangoModelFactory):
status = CourseRun.Status.Published
uuid = factory.LazyFunction(uuid4) uuid = factory.LazyFunction(uuid4)
key = FuzzyText(prefix='course-run-id/', suffix='/fake') key = FuzzyText(prefix='course-run-id/', suffix='/fake')
course = factory.SubFactory(CourseFactory) course = factory.SubFactory(CourseFactory)
...@@ -122,7 +123,7 @@ class CourseRunFactory(factory.DjangoModelFactory): ...@@ -122,7 +123,7 @@ class CourseRunFactory(factory.DjangoModelFactory):
video = factory.SubFactory(VideoFactory) video = factory.SubFactory(VideoFactory)
min_effort = FuzzyInteger(1, 10) min_effort = FuzzyInteger(1, 10)
max_effort = FuzzyInteger(10, 20) max_effort = FuzzyInteger(10, 20)
pacing_type = FuzzyChoice([name for name, __ in CourseRun.PACING_CHOICES]) pacing_type = FuzzyChoice([name for name, __ in CourseRun.Pacing.choices])
slug = FuzzyText() slug = FuzzyText()
@factory.post_generation @factory.post_generation
...@@ -239,7 +240,7 @@ class ProgramFactory(factory.django.DjangoModelFactory): ...@@ -239,7 +240,7 @@ class ProgramFactory(factory.django.DjangoModelFactory):
uuid = factory.LazyFunction(uuid4) uuid = factory.LazyFunction(uuid4)
subtitle = 'test-subtitle' subtitle = 'test-subtitle'
type = factory.SubFactory(ProgramTypeFactory) type = factory.SubFactory(ProgramTypeFactory)
status = Program.ProgramStatus.Unpublished status = Program.Status.Unpublished
marketing_slug = factory.Sequence(lambda n: 'test-slug-{}'.format(n)) # pylint: disable=unnecessary-lambda marketing_slug = factory.Sequence(lambda n: 'test-slug-{}'.format(n)) # pylint: disable=unnecessary-lambda
banner_image_url = FuzzyText(prefix='https://example.com/program/banner') banner_image_url = FuzzyText(prefix='https://example.com/program/banner')
card_image_url = FuzzyText(prefix='https://example.com/program/card') card_image_url = FuzzyText(prefix='https://example.com/program/card')
......
...@@ -109,7 +109,7 @@ class AdminTests(TestCase): ...@@ -109,7 +109,7 @@ class AdminTests(TestCase):
def test_program_without_image_and_active_status(self): def test_program_without_image_and_active_status(self):
""" Verify that new program cannot be added without `image` and active status together.""" """ Verify that new program cannot be added without `image` and active status together."""
data = self._post_data(Program.ProgramStatus.Active) data = self._post_data(Program.Status.Active)
form = ProgramAdminForm(data, {'banner_image': ''}) form = ProgramAdminForm(data, {'banner_image': ''})
self.assertFalse(form.is_valid()) self.assertFalse(form.is_valid())
self.assertEqual(form.errors['__all__'], ['Status cannot be change to active without banner image.']) self.assertEqual(form.errors['__all__'], ['Status cannot be change to active without banner image.'])
...@@ -117,9 +117,9 @@ class AdminTests(TestCase): ...@@ -117,9 +117,9 @@ class AdminTests(TestCase):
form.save() form.save()
@ddt.data( @ddt.data(
Program.ProgramStatus.Deleted, Program.Status.Deleted,
Program.ProgramStatus.Retired, Program.Status.Retired,
Program.ProgramStatus.Unpublished Program.Status.Unpublished
) )
def test_program_without_image_and_non_active_status(self, status): def test_program_without_image_and_non_active_status(self, status):
""" Verify that new program can be added without `image` and non-active """ Verify that new program can be added without `image` and non-active
...@@ -129,10 +129,10 @@ class AdminTests(TestCase): ...@@ -129,10 +129,10 @@ class AdminTests(TestCase):
self.valid_post_form(data, {'banner_image': ''}) self.valid_post_form(data, {'banner_image': ''})
@ddt.data( @ddt.data(
Program.ProgramStatus.Deleted, Program.Status.Deleted,
Program.ProgramStatus.Retired, Program.Status.Retired,
Program.ProgramStatus.Unpublished, Program.Status.Unpublished,
Program.ProgramStatus.Active Program.Status.Active
) )
def test_program_with_image(self, status): def test_program_with_image(self, status):
""" Verify that new program can be added with `image` and any status.""" """ Verify that new program can be added with `image` and any status."""
...@@ -157,7 +157,7 @@ class AdminTests(TestCase): ...@@ -157,7 +157,7 @@ class AdminTests(TestCase):
def test_new_program_without_courses(self): def test_new_program_without_courses(self):
""" Verify that new program can be added without `courses`.""" """ Verify that new program can be added without `courses`."""
data = self._post_data(Program.ProgramStatus.Unpublished) data = self._post_data(Program.Status.Unpublished)
data['courses'] = [] data['courses'] = []
form = ProgramAdminForm(data) form = ProgramAdminForm(data)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import djchoices.choices
class Migration(migrations.Migration):
dependencies = [
('publisher', '0004_auto_20160810_0854'),
]
operations = [
migrations.AlterField(
model_name='courserun',
name='pacing_type',
field=models.CharField(max_length=255, null=True, blank=True, choices=[('instructor_paced', 'Instructor-paced'), ('self_paced', 'Self-paced')], db_index=True, validators=[djchoices.choices.ChoicesValidator({'self_paced': 'Self-paced', 'instructor_paced': 'Instructor-paced'})]),
),
migrations.AlterField(
model_name='historicalcourserun',
name='pacing_type',
field=models.CharField(max_length=255, null=True, blank=True, choices=[('instructor_paced', 'Instructor-paced'), ('self_paced', 'Self-paced')], db_index=True, validators=[djchoices.choices.ChoicesValidator({'self_paced': 'Self-paced', 'instructor_paced': 'Instructor-paced'})]),
),
]
...@@ -138,7 +138,8 @@ class CourseRun(TimeStampedModel, ChangedByMixin): ...@@ -138,7 +138,8 @@ class CourseRun(TimeStampedModel, ChangedByMixin):
enrollment_end = models.DateTimeField(null=True, blank=True) enrollment_end = models.DateTimeField(null=True, blank=True)
certificate_generation = models.DateTimeField(null=True, blank=True) certificate_generation = models.DateTimeField(null=True, blank=True)
pacing_type = models.CharField( pacing_type = models.CharField(
max_length=255, choices=CourseMetadataCourseRun.PACING_CHOICES, db_index=True, null=True, blank=True max_length=255, db_index=True, null=True, blank=True, choices=CourseMetadataCourseRun.Pacing.choices,
validators=[CourseMetadataCourseRun.Pacing.validator]
) )
staff = SortedManyToManyField(Person, blank=True, related_name='publisher_course_runs_staffed') staff = SortedManyToManyField(Person, blank=True, related_name='publisher_course_runs_staffed')
min_effort = models.PositiveSmallIntegerField( min_effort = models.PositiveSmallIntegerField(
......
...@@ -47,7 +47,7 @@ class CourseRunFactory(factory.DjangoModelFactory): ...@@ -47,7 +47,7 @@ class CourseRunFactory(factory.DjangoModelFactory):
min_effort = FuzzyInteger(1, 10) min_effort = FuzzyInteger(1, 10)
max_effort = FuzzyInteger(10, 20) max_effort = FuzzyInteger(10, 20)
language = factory.Iterator(LanguageTag.objects.all()) language = factory.Iterator(LanguageTag.objects.all())
pacing_type = FuzzyChoice([name for name, __ in CourseMetadataCourseRun.PACING_CHOICES]) pacing_type = FuzzyChoice([name for name, __ in CourseMetadataCourseRun.Pacing.choices])
length = FuzzyInteger(1, 10) length = FuzzyInteger(1, 10)
seo_review = "test-seo-review" seo_review = "test-seo-review"
keywords = "Test1, Test2, Test3" keywords = "Test1, Test2, Test3"
......
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