Commit ad501533 by Clinton Blackburn Committed by GitHub

Updated Program model with MicroMasters fields (#196)

ECOM-5094
parent ae5dcc2c
......@@ -50,8 +50,8 @@ PROGRAM_FACET_FIELD_OPTIONS = {
}
PROGRAM_SEARCH_FIELDS = (
'text', 'uuid', 'title', 'subtitle', 'category', 'marketing_url', 'organizations', 'content_type', 'image_url',
'status',
'text', 'uuid', 'title', 'subtitle', 'category', 'marketing_url', 'organizations', 'content_type', 'status',
'card_image_url',
)
......@@ -264,7 +264,7 @@ class ContainedCoursesSerializer(serializers.Serializer):
class ProgramSerializer(serializers.ModelSerializer):
class Meta:
model = Program
fields = ('uuid', 'title', 'subtitle', 'category', 'marketing_slug', 'marketing_url', 'image_url',)
fields = ('uuid', 'title', 'subtitle', 'category', 'marketing_slug', 'marketing_url', 'card_image_url',)
read_only_fields = ('uuid', 'marketing_url',)
......
......@@ -181,7 +181,7 @@ class ProgramSerializerTests(TestCase):
'category': program.category,
'marketing_slug': program.marketing_slug,
'marketing_url': program.marketing_url,
'image_url': program.image_url,
'card_image_url': program.card_image_url,
}
self.assertDictEqual(serializer.data, expected)
......@@ -386,9 +386,12 @@ class CourseRunSearchSerializerTests(TestCase):
class ProgramSearchSerializerTests(TestCase):
def test_data(self):
program = ProgramFactory()
organization = OrganizationFactory()
program.organizations.add(organization)
authoring_organization, crediting_organization = OrganizationFactory.create_batch(2)
program.authoring_organizations.add(authoring_organization)
program.credit_backing_organizations.add(crediting_organization)
program.save()
expected_organizations = [OrganizationsMixin.format_organization(org) for org in
(authoring_organization, crediting_organization)]
# NOTE: This serializer expects SearchQuerySet results, so we run a search on the newly-created object
# to generate such a result.
......@@ -401,9 +404,9 @@ class ProgramSearchSerializerTests(TestCase):
'subtitle': program.subtitle,
'category': program.category,
'marketing_url': program.marketing_url,
'organizations': [OrganizationsMixin.format_organization(organization)],
'organizations': expected_organizations,
'content_type': 'program',
'image_url': program.image_url,
'card_image_url': program.card_image_url,
'status': program.status,
}
self.assertDictEqual(serializer.data, expected)
from django.contrib import admin
from course_discovery.apps.course_metadata.models import (
Seat, Image, Video, LevelType, Subject, Prerequisite, ExpectedLearningItem, Expertise,
Course, CourseRun, CourseRunSocialNetwork, MajorWork, Organization, Person, PersonSocialNetwork,
CourseOrganization, SyllabusItem, Program
)
Seat, Image, Video, LevelType, Subject, Prerequisite, ExpectedLearningItem, Expertise, Course, CourseRun,
CourseRunSocialNetwork, MajorWork, Organization, Person, PersonSocialNetwork, CourseOrganization, SyllabusItem,
Program, JobOutlookItem, SeatType, Endorsement, CorporateEndorsement, FAQ, ProgramType)
class CourseOrganizationInline(admin.TabularInline):
......@@ -21,6 +20,7 @@ class SeatInline(admin.TabularInline):
class CourseAdmin(admin.ModelAdmin):
inlines = (CourseOrganizationInline,)
list_display = ('key', 'title',)
list_filter = ('partner',)
ordering = ('key', 'title',)
search_fields = ('key', 'title',)
......@@ -36,10 +36,38 @@ class CourseRunAdmin(admin.ModelAdmin):
@admin.register(Program)
class ProgramAdmin(admin.ModelAdmin):
list_display = ('uuid', 'title',)
list_filter = ('partner',)
ordering = ('uuid', 'title',)
readonly_fields = ('uuid',)
search_fields = ('uuid', 'title', 'marketing_slug')
@admin.register(ProgramType)
class ProgramTypeAdmin(admin.ModelAdmin):
list_display = ('name',)
@admin.register(SeatType)
class SeatTypeAdmin(admin.ModelAdmin):
list_display = ('name', 'slug',)
readonly_fields = ('slug',)
@admin.register(Endorsement)
class EndorsementAdmin(admin.ModelAdmin):
list_display = ('endorser',)
@admin.register(CorporateEndorsement)
class CorporateEndorsementAdmin(admin.ModelAdmin):
list_display = ('corporation_name',)
@admin.register(FAQ)
class FAQAdmin(admin.ModelAdmin):
list_display = ('question',)
class KeyNameAdmin(admin.ModelAdmin):
list_display = ('key', 'name',)
ordering = ('key', 'name',)
......@@ -61,5 +89,6 @@ for model in (LevelType, Subject, Prerequisite, Expertise, MajorWork):
admin.site.register(model, NamedModelAdmin)
# Register remaining models using basic ModelAdmin classes
for model in (Image, Video, ExpectedLearningItem, SyllabusItem, PersonSocialNetwork, CourseRunSocialNetwork):
for model in (Image, Video, ExpectedLearningItem, SyllabusItem, PersonSocialNetwork, CourseRunSocialNetwork,
JobOutlookItem,):
admin.site.register(model)
......@@ -307,7 +307,8 @@ class DrupalApiDataLoader(AbstractDataLoader):
# Clean Organizations separately from other orphaned instances to avoid removing all orgnaziations
# after an initial data load on an empty table.
Organization.objects.filter(courseorganization__isnull=True, program__isnull=True).delete()
Organization.objects.filter(courseorganization__isnull=True, authored_programs__isnull=True,
credit_backed_programs__isnull=True).delete()
self.delete_orphans()
logger.info('Retrieved %d course runs from %s.', len(data), api_url)
......@@ -545,7 +546,7 @@ class ProgramsApiDataLoader(AbstractDataLoader):
'category': body['category'],
'status': body['status'],
'marketing_slug': body['marketing_slug'],
'image': self._get_image(body),
'banner_image_url': self._get_banner_image_url(body),
'partner': self.partner,
}
......@@ -558,24 +559,15 @@ class ProgramsApiDataLoader(AbstractDataLoader):
)
organizations.append(organization)
program.organizations.clear()
program.organizations.add(*organizations)
program.authoring_organizations.clear()
program.authoring_organizations.add(*organizations)
except Exception: # pylint: disable=broad-except
logger.exception('Failed to load program %s', uuid)
def _get_image(self, body):
image = None
def _get_banner_image_url(self, body):
image_key = 'w{width}h{height}'.format(width=self.image_width, height=self.image_height)
image_url = body.get('banner_image_urls', {}).get(image_key)
if image_url:
defaults = {
'width': self.image_width,
'height': self.image_height,
}
image, __ = Image.objects.update_or_create(src=image_url, defaults=defaults)
return image
return image_url
class MarketingSiteDataLoader(AbstractDataLoader):
......@@ -667,10 +659,7 @@ class MarketingSiteDataLoader(AbstractDataLoader):
'subtitle': data.get('field_xseries_subtitle_short'),
'category': 'XSeries',
'partner': self.partner,
'card_image_url': card_image_url,
}
if card_image_url:
card_image, __ = Image.objects.get_or_create(src=card_image_url)
defaults['image'] = card_image
Program.objects.update_or_create(marketing_slug=marketing_slug, defaults=defaults)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import sortedm2m.fields
import djchoices.choices
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0010_auto_20160731_0226'),
]
operations = [
migrations.CreateModel(
name='CorporateEndorsement',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)),
('corporation_name', models.CharField(max_length=128)),
('statement', models.TextField()),
('image', models.ForeignKey(blank=True, to='course_metadata.Image', null=True)),
],
options={
'ordering': ('-modified', '-created'),
'abstract': False,
'get_latest_by': 'modified',
},
),
migrations.CreateModel(
name='Endorsement',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)),
('quote', models.TextField()),
('endorser', models.ForeignKey(to='course_metadata.Person')),
],
options={
'ordering': ('-modified', '-created'),
'abstract': False,
'get_latest_by': 'modified',
},
),
migrations.CreateModel(
name='FAQ',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)),
('question', models.TextField()),
('answer', models.TextField()),
],
options={
'verbose_name': 'FAQ',
'verbose_name_plural': 'FAQs',
},
),
migrations.CreateModel(
name='JobOutlookItem',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)),
('value', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ProgramType',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)),
('name', models.CharField(max_length=32, unique=True)),
],
options={
'ordering': ('-modified', '-created'),
'abstract': False,
'get_latest_by': 'modified',
},
),
migrations.CreateModel(
name='SeatType',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('created', django_extensions.db.fields.CreationDateTimeField(verbose_name='created', auto_now_add=True)),
('modified', django_extensions.db.fields.ModificationDateTimeField(verbose_name='modified', auto_now=True)),
('name', models.CharField(max_length=64, unique=True)),
('slug', django_extensions.db.fields.AutoSlugField(editable=False, populate_from='name', blank=True)),
],
options={
'ordering': ('-modified', '-created'),
'abstract': False,
'get_latest_by': 'modified',
},
),
migrations.RemoveField(
model_name='program',
name='image',
),
migrations.RemoveField(
model_name='program',
name='organizations',
),
migrations.AddField(
model_name='historicalorganization',
name='banner_image',
field=models.ForeignKey(blank=True, related_name='+', on_delete=django.db.models.deletion.DO_NOTHING, db_constraint=False, to='course_metadata.Image', null=True),
),
migrations.AddField(
model_name='organization',
name='banner_image',
field=models.ForeignKey(blank=True, related_name='bannered_organizations', to='course_metadata.Image', null=True),
),
migrations.AddField(
model_name='program',
name='authoring_organizations',
field=sortedm2m.fields.SortedManyToManyField(related_name='authored_programs', help_text=None, blank=True, to='course_metadata.Organization'),
),
migrations.AddField(
model_name='program',
name='banner_image_url',
field=models.URLField(help_text='Image used atop detail pages', blank=True, null=True),
),
migrations.AddField(
model_name='program',
name='card_image_url',
field=models.URLField(help_text='Image used for discovery cards', blank=True, null=True),
),
migrations.AddField(
model_name='program',
name='courses',
field=models.ManyToManyField(to='course_metadata.Course'),
),
migrations.AddField(
model_name='program',
name='credit_backing_organizations',
field=sortedm2m.fields.SortedManyToManyField(related_name='credit_backed_programs', help_text=None, blank=True, to='course_metadata.Organization'),
),
migrations.AddField(
model_name='program',
name='excluded_course_runs',
field=models.ManyToManyField(to='course_metadata.CourseRun'),
),
migrations.AddField(
model_name='program',
name='expected_learning_items',
field=sortedm2m.fields.SortedManyToManyField(help_text=None, blank=True, to='course_metadata.ExpectedLearningItem'),
),
migrations.AddField(
model_name='program',
name='max_hours_effort_per_week',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='program',
name='min_hours_effort_per_week',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='program',
name='overview',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='program',
name='video',
field=models.ForeignKey(default=None, blank=True, to='course_metadata.Video', null=True),
),
migrations.AddField(
model_name='program',
name='weeks_to_complete',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='organization',
name='logo_image',
field=models.ForeignKey(blank=True, related_name='logoed_organizations', to='course_metadata.Image', null=True),
),
migrations.AlterField(
model_name='program',
name='status',
field=models.CharField(choices=[('unpublished', 'Unpublished'), ('active', 'Active'), ('retired', 'Retired'), ('deleted', 'Deleted')], validators=[djchoices.choices.ChoicesValidator({'deleted': 'Deleted', 'retired': 'Retired', 'active': 'Active', 'unpublished': 'Unpublished'})], max_length=24, help_text='The lifecycle status of this Program.'),
),
migrations.AddField(
model_name='programtype',
name='applicable_seat_types',
field=models.ManyToManyField(to='course_metadata.SeatType', help_text='Seat types that qualify for completion of programs of this type. Learners completing associated courses, but enrolled in other seat types, will NOT have their completion of the course counted toward the completion of the program.'),
),
migrations.AddField(
model_name='corporateendorsement',
name='individual_endorsements',
field=sortedm2m.fields.SortedManyToManyField(to='course_metadata.Endorsement', help_text=None),
),
migrations.AddField(
model_name='program',
name='corporate_endorsements',
field=sortedm2m.fields.SortedManyToManyField(help_text=None, blank=True, to='course_metadata.CorporateEndorsement'),
),
migrations.AddField(
model_name='program',
name='faq',
field=sortedm2m.fields.SortedManyToManyField(help_text=None, blank=True, to='course_metadata.FAQ'),
),
migrations.AddField(
model_name='program',
name='individual_endorsements',
field=sortedm2m.fields.SortedManyToManyField(help_text=None, blank=True, to='course_metadata.Endorsement'),
),
migrations.AddField(
model_name='program',
name='job_outlook_items',
field=sortedm2m.fields.SortedManyToManyField(help_text=None, blank=True, to='course_metadata.JobOutlookItem'),
),
migrations.AddField(
model_name='program',
name='type',
field=models.ForeignKey(blank=True, to='course_metadata.ProgramType', null=True),
),
]
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
SEAT_TYPES = ('Audit', 'Credit', 'Professional', 'Verified',)
def add_seat_types(apps, schema_editor):
SeatType = apps.get_model('course_metadata', 'SeatType')
for name in SEAT_TYPES:
SeatType.objects.update_or_create(name=name)
def drop_seat_types(apps, schema_editor):
SeatType = apps.get_model('course_metadata', 'SeatType')
SeatType.objects.filter(name__in=SEAT_TYPES).delete()
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0011_auto_20160805_1949'),
]
operations = [
migrations.RunPython(add_seat_types, drop_seat_types)
]
import datetime
import itertools
import logging
from urllib.parse import urljoin
from uuid import uuid4
......@@ -7,7 +8,9 @@ import pytz
from django.db import models
from django.db.models.query_utils import Q
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields import AutoSlugField
from django_extensions.db.models import TimeStampedModel
from djchoices import DjangoChoices, ChoiceItem
from haystack.query import SearchQuerySet
from simple_history.models import HistoricalRecords
from sortedm2m.fields import SortedManyToManyField
......@@ -109,6 +112,11 @@ class ExpectedLearningItem(AbstractValueModel):
pass
class JobOutlookItem(AbstractValueModel):
""" JobOutlookItem model. """
pass
class SyllabusItem(AbstractValueModel):
""" SyllabusItem model. """
parent = models.ForeignKey('self', blank=True, null=True, related_name='children')
......@@ -130,7 +138,10 @@ class Organization(TimeStampedModel):
name = models.CharField(max_length=255, null=True, blank=True)
description = models.TextField(null=True, blank=True)
homepage_url = models.URLField(max_length=255, null=True, blank=True)
logo_image = models.ForeignKey(Image, null=True, blank=True)
# NOTE (CCB): The related_name values are here to prevent the images from being treated as orphans.
logo_image = models.ForeignKey(Image, null=True, blank=True, related_name='logoed_organizations')
banner_image = models.ForeignKey(Image, null=True, blank=True, related_name='bannered_organizations')
partner = models.ForeignKey(Partner, null=True, blank=False)
history = HistoricalRecords()
......@@ -186,10 +197,10 @@ class Course(TimeStampedModel):
"Course number format e.g CS002x, BIO1.1x, BIO1.2x"
)
)
partner = models.ForeignKey(Partner, null=True, blank=False)
history = HistoricalRecords()
objects = CourseQuerySet.as_manager()
partner = models.ForeignKey(Partner, null=True, blank=False)
@property
def owners(self):
......@@ -396,6 +407,11 @@ class CourseRun(TimeStampedModel):
return '{key}: {title}'.format(key=self.key, title=self.title)
class SeatType(TimeStampedModel):
name = models.CharField(max_length=64, unique=True)
slug = AutoSlugField(populate_from='name')
class Seat(TimeStampedModel):
""" Seat model. """
HONOR = 'honor'
......@@ -419,6 +435,7 @@ class Seat(TimeStampedModel):
'default': 0.00,
}
course_run = models.ForeignKey(CourseRun, related_name='seats')
# TODO Replace with FK to SeatType model
type = models.CharField(max_length=63, choices=SEAT_TYPE_CHOICES)
price = models.DecimalField(**PRICE_FIELD_CONFIG)
currency = models.ForeignKey(Currency)
......@@ -457,52 +474,80 @@ class CourseOrganization(TimeStampedModel):
)
class Program(TimeStampedModel):
"""
Representation of a Program.
"""
uuid = models.UUIDField(
blank=True,
default=uuid4,
editable=False,
unique=True,
verbose_name=_('UUID')
)
class Endorsement(TimeStampedModel):
endorser = models.ForeignKey(Person, blank=False, null=False)
quote = models.TextField(blank=False, null=False)
title = models.CharField(
help_text=_('The user-facing display title for this Program.'),
max_length=255,
unique=True,
)
subtitle = models.CharField(
help_text=_('A brief, descriptive subtitle for the Program.'),
max_length=255,
blank=True,
)
class CorporateEndorsement(TimeStampedModel):
corporation_name = models.CharField(max_length=128, blank=False, null=False)
statement = models.TextField(blank=False, null=False)
image = models.ForeignKey(Image, blank=True, null=True)
individual_endorsements = SortedManyToManyField(Endorsement)
category = models.CharField(
help_text=_('The category / type of Program.'),
max_length=32,
)
status = models.CharField(
help_text=_('The lifecycle status of this Program.'),
max_length=24,
)
class FAQ(TimeStampedModel):
question = models.TextField(blank=False, null=False)
answer = models.TextField(blank=False, null=False)
marketing_slug = models.CharField(
help_text=_('Slug used to generate links to the marketing site'),
blank=True,
max_length=255,
db_index=True
class Meta:
verbose_name = _('FAQ')
verbose_name_plural = _('FAQs')
class ProgramType(TimeStampedModel):
name = models.CharField(max_length=32, unique=True, null=False, blank=False)
applicable_seat_types = models.ManyToManyField(
SeatType, help_text=_('Seat types that qualify for completion of programs of this type. Learners completing '
'associated courses, but enrolled in other seat types, will NOT have their completion '
'of the course counted toward the completion of the program.'),
)
image = models.ForeignKey(Image, default=None, null=True, blank=True)
organizations = models.ManyToManyField(Organization, blank=True)
class Program(TimeStampedModel):
class ProgramStatus(DjangoChoices):
Unpublished = ChoiceItem('unpublished', _('Unpublished'))
Active = ChoiceItem('active', _('Active'))
Retired = ChoiceItem('retired', _('Retired'))
Deleted = ChoiceItem('deleted', _('Deleted'))
uuid = models.UUIDField(blank=True, default=uuid4, editable=False, unique=True, verbose_name=_('UUID'))
title = models.CharField(
help_text=_('The user-facing display title for this Program.'), max_length=255, unique=True)
subtitle = models.CharField(
help_text=_('A brief, descriptive subtitle for the Program.'), max_length=255, blank=True)
# TODO Remove category in favor of type
category = models.CharField(help_text=_('The category / type of Program.'), max_length=32)
type = models.ForeignKey(ProgramType, null=True, blank=True)
status = models.CharField(
help_text=_('The lifecycle status of this Program.'), max_length=24, null=False, blank=False,
choices=ProgramStatus.choices, validators=[ProgramStatus.validator]
)
marketing_slug = models.CharField(
help_text=_('Slug used to generate links to the marketing site'), blank=True, max_length=255, db_index=True)
courses = models.ManyToManyField(Course)
# NOTE (CCB): Editors of this field should validate the values to ensure only CourseRuns associated
# with related Courses are stored.
excluded_course_runs = models.ManyToManyField(CourseRun)
partner = models.ForeignKey(Partner, null=True, blank=False)
overview = models.TextField(null=True, blank=True)
weeks_to_complete = models.PositiveSmallIntegerField(null=True, blank=True)
min_hours_effort_per_week = models.PositiveSmallIntegerField(null=True, blank=True)
max_hours_effort_per_week = models.PositiveSmallIntegerField(null=True, blank=True)
authoring_organizations = SortedManyToManyField(Organization, blank=True, related_name='authored_programs')
banner_image_url = models.URLField(null=True, blank=True, help_text=_('Image used atop detail pages'))
card_image_url = models.URLField(null=True, blank=True, help_text=_('Image used for discovery cards'))
video = models.ForeignKey(Video, default=None, null=True, blank=True)
expected_learning_items = SortedManyToManyField(ExpectedLearningItem, blank=True)
faq = SortedManyToManyField(FAQ, blank=True)
credit_backing_organizations = SortedManyToManyField(
Organization, blank=True, related_name='credit_backed_programs'
)
corporate_endorsements = SortedManyToManyField(CorporateEndorsement, blank=True)
job_outlook_items = SortedManyToManyField(JobOutlookItem, blank=True)
individual_endorsements = SortedManyToManyField(Endorsement, blank=True)
def __str__(self):
return self.title
......@@ -516,11 +561,52 @@ class Program(TimeStampedModel):
return None
@property
def image_url(self):
if self.image:
return self.image.src
def course_runs(self):
excluded_course_run_ids = [course_run.id for course_run in self.excluded_course_runs.all()]
return CourseRun.objects.filter(course__program=self).exclude(id__in=excluded_course_run_ids)
return None
@property
def languages(self):
return set([course_run.language for course_run in self.course_runs])
@property
def transcript_languages(self):
languages = [list(course_run.transcript_languages.all()) for course_run in self.course_runs]
languages = itertools.chain.from_iterable(languages)
return set(languages)
@property
def subjects(self):
subjects = [list(course.subjects.all()) for course in self.courses.all()]
subjects = itertools.chain.from_iterable(subjects)
return set(subjects)
@property
def price_ranges(self):
applicable_seat_types = self.type.applicable_seat_types.values_list('slug', flat=True)
seats = Seat.objects.filter(course_run__in=self.course_runs, type__in=applicable_seat_types) \
.values('currency') \
.annotate(models.Min('price'), models.Max('price'))
price_ranges = []
for seat in seats:
price_ranges.append({
'currency': seat['currency'],
'min': seat['price__min'],
'max': seat['price__max'],
})
return price_ranges
@property
def start(self):
""" Start datetime, calculated by determining the earliest start datetime of all related course runs. """
return min([course_run.start for course_run in self.course_runs])
@property
def instructors(self):
instructors = [list(course_run.instructors.all()) for course_run in self.course_runs]
instructors = itertools.chain.from_iterable(instructors)
return set(instructors)
class PersonSocialNetwork(AbstractSocialNetworkModel):
......
......@@ -106,7 +106,7 @@ class CourseRunIndex(BaseCourseIndex, indexes.Indexable):
return [self._prepare_language(language) for language in obj.transcript_languages.all()]
class ProgramIndex(OrganizationsMixin, BaseIndex, indexes.Indexable):
class ProgramIndex(BaseIndex, indexes.Indexable, OrganizationsMixin):
model = Program
uuid = indexes.CharField(model_attr='uuid')
......@@ -115,9 +115,20 @@ class ProgramIndex(OrganizationsMixin, BaseIndex, indexes.Indexable):
category = indexes.CharField(model_attr='category', faceted=True)
marketing_url = indexes.CharField(null=True)
organizations = indexes.MultiValueField(faceted=True)
image_url = indexes.CharField(model_attr='image_url', null=True)
authoring_organizations = indexes.MultiValueField(faceted=True)
credit_backing_organizations = indexes.MultiValueField(faceted=True)
card_image_url = indexes.CharField(model_attr='card_image_url', null=True)
status = indexes.CharField(model_attr='status', faceted=True)
partner = indexes.CharField(model_attr='partner__name', null=True, faceted=True)
def prepare_organizations(self, obj):
return self.prepare_authoring_organizations(obj) + self.prepare_credit_backing_organizations(obj)
def prepare_authoring_organizations(self, obj):
return [self.format_organization(organization) for organization in obj.authoring_organizations.all()]
def prepare_credit_backing_organizations(self, obj):
return [self.format_organization(organization) for organization in obj.credit_backing_organizations.all()]
def prepare_marketing_url(self, obj):
return obj.marketing_url
......@@ -7,16 +7,18 @@ from factory.fuzzy import (
)
from pytz import UTC
from course_discovery.apps.core.models import Currency
from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.core.tests.utils import FuzzyURL
from course_discovery.apps.core.models import Currency
from course_discovery.apps.course_metadata.models import (
Course, CourseRun, Organization, Person, Image, Video, Subject, Seat, Prerequisite, LevelType, Program,
AbstractSocialNetworkModel, CourseRunSocialNetwork, PersonSocialNetwork
AbstractSocialNetworkModel, CourseRunSocialNetwork, PersonSocialNetwork, ProgramType
)
from course_discovery.apps.ietf_language_tags.models import LanguageTag
# pylint: disable=no-member, unused-argument
class AbstractMediaModelFactory(factory.DjangoModelFactory):
src = FuzzyURL()
description = FuzzyText()
......@@ -80,6 +82,16 @@ class CourseFactory(factory.DjangoModelFactory):
class Meta:
model = Course
@factory.post_generation
def subjects(self, create, extracted, **kwargs):
if not create: # pragma: no cover
# Simple build, do nothing.
return
if extracted:
for subject in extracted:
self.subjects.add(subject)
class CourseRunFactory(factory.DjangoModelFactory):
key = FuzzyText(prefix='course-run-id/', suffix='/fake')
......@@ -103,6 +115,16 @@ class CourseRunFactory(factory.DjangoModelFactory):
class Meta:
model = CourseRun
@factory.post_generation
def transcript_languages(self, create, extracted, **kwargs):
if not create: # pragma: no cover
# Simple build, do nothing.
return
if extracted:
for transcript_language in extracted:
self.transcript_languages.add(transcript_language)
class OrganizationFactory(factory.DjangoModelFactory):
key = FuzzyText(prefix='Org.fake/')
......@@ -127,6 +149,23 @@ class PersonFactory(factory.DjangoModelFactory):
model = Person
class ProgramTypeFactory(factory.django.DjangoModelFactory):
class Meta(object):
model = ProgramType
name = FuzzyText()
@factory.post_generation
def applicable_seat_types(self, create, extracted, **kwargs):
if not create: # pragma: no cover
# Simple build, do nothing.
return
if extracted:
for seat_type in extracted:
self.applicable_seat_types.add(seat_type)
class ProgramFactory(factory.django.DjangoModelFactory):
class Meta(object):
model = Program
......@@ -137,9 +176,31 @@ class ProgramFactory(factory.django.DjangoModelFactory):
category = 'xseries'
status = 'unpublished'
marketing_slug = factory.Sequence(lambda n: 'test-slug-{}'.format(n)) # pylint: disable=unnecessary-lambda
image = factory.SubFactory(ImageFactory)
banner_image_url = FuzzyText(prefix='https://example.com/program/banner')
card_image_url = FuzzyText(prefix='https://example.com/program/card')
partner = factory.SubFactory(PartnerFactory)
@factory.post_generation
def courses(self, create, extracted, **kwargs):
if not create: # pragma: no cover
# Simple build, do nothing.
return
if extracted:
# Use the passed in list of courses
for course in extracted:
self.courses.add(course)
@factory.post_generation
def excluded_course_runs(self, create, extracted, **kwargs):
if not create: # pragma: no cover
# Simple build, do nothing.
return
if extracted:
for course_run in extracted:
self.excluded_course_runs.add(course_run)
class AbstractSocialNetworkModelFactory(factory.DjangoModelFactory):
type = FuzzyChoice([name for name, __ in AbstractSocialNetworkModel.SOCIAL_NETWORK_CHOICES])
......
......@@ -636,13 +636,10 @@ class ProgramsApiDataLoaderTests(ApiClientTestMixin, DataLoaderTestMixin, TestCa
keys = [org['key'] for org in body['organizations']]
expected_organizations = list(Organization.objects.filter(key__in=keys))
self.assertEqual(keys, [org.key for org in expected_organizations])
self.assertListEqual(list(program.organizations.all()), expected_organizations)
self.assertListEqual(list(program.authoring_organizations.all()), expected_organizations)
image_url = body.get('banner_image_urls', {}).get('w435h145')
if image_url:
image = Image.objects.get(src=image_url, width=self.loader.image_width,
height=self.loader.image_height)
self.assertEqual(program.image, image)
banner_image_url = body.get('banner_image_urls', {}).get('w435h145')
self.assertEqual(program.banner_image_url, banner_image_url)
@responses.activate
def test_ingest(self):
......@@ -736,12 +733,7 @@ class MarketingSiteDataLoaderTests(DataLoaderTestMixin, TestCase):
self.assertEqual(program.partner, self.partner)
card_image_url = data.get('field_card_image', {}).get('url')
if card_image_url:
card_image = Image.objects.get(src=card_image_url)
self.assertEqual(program.image, card_image)
else:
self.assertIsNone(program.image)
self.assertEqual(program.card_image_url, card_image_url)
def test_constructor_without_credentials(self):
""" Verify the constructor raises an exception if the Partner has no marketing site credentials set. """
......
import datetime
import itertools
from decimal import Decimal
import ddt
import mock
import pytz
from dateutil.parser import parse
from django.db import IntegrityError
from django.test import TestCase
from freezegun import freeze_time
from course_discovery.apps.core.models import Currency
from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.models import (
AbstractNamedModel, AbstractMediaModel, AbstractValueModel, CourseOrganization, Course, CourseRun
)
AbstractNamedModel, AbstractMediaModel, AbstractValueModel, CourseOrganization, Course, CourseRun,
SeatType)
from course_discovery.apps.course_metadata.tests import factories
from course_discovery.apps.ietf_language_tags.models import LanguageTag
# pylint: disable=no-member
class CourseTests(TestCase):
""" Tests for the `Course` model. """
......@@ -270,7 +274,13 @@ class ProgramTests(TestCase):
def setUp(self):
super(ProgramTests, self).setUp()
self.program = factories.ProgramFactory()
transcript_languages = LanguageTag.objects.all()[:2]
subjects = factories.SubjectFactory.create_batch(2)
self.course_runs = factories.CourseRunFactory.create_batch(
3, transcript_languages=transcript_languages, course__subjects=subjects)
self.courses = [course_run.course for course_run in self.course_runs]
self.excluded_course_run = factories.CourseRunFactory(course=self.courses[0])
self.program = factories.ProgramFactory(courses=self.courses, excluded_course_runs=[self.excluded_course_run])
def test_str(self):
"""Verify that a program is properly converted to a str."""
......@@ -287,13 +297,56 @@ class ProgramTests(TestCase):
self.program.marketing_slug = ''
self.assertIsNone(self.program.marketing_url)
def test_image_url(self):
""" Verify the property returns the associated image's URL. """
self.assertEqual(self.program.image_url, self.program.image.src)
self.program.image = None
self.assertIsNone(self.program.image)
self.assertIsNone(self.program.image_url)
def test_course_runs(self):
""" Verify the property returns the set of associated CourseRuns minus those that are explicitly excluded. """
self.assertEqual(set(self.program.course_runs), set(self.course_runs))
def test_languages(self):
expected_languages = set([course_run.language for course_run in self.course_runs])
actual_languages = self.program.languages
self.assertGreater(len(actual_languages), 0)
self.assertEqual(actual_languages, expected_languages)
def test_transcript_languages(self):
expected_transcript_languages = itertools.chain.from_iterable(
[list(course_run.transcript_languages.all()) for course_run in self.course_runs])
expected_transcript_languages = set(expected_transcript_languages)
actual_transcript_languages = self.program.transcript_languages
self.assertGreater(len(actual_transcript_languages), 0)
self.assertEqual(actual_transcript_languages, expected_transcript_languages)
def test_subjects(self):
expected_subjects = itertools.chain.from_iterable([list(course.subjects.all()) for course in self.courses])
expected_subjects = set(expected_subjects)
actual_subjects = self.program.subjects
self.assertGreater(len(actual_subjects), 0)
self.assertEqual(actual_subjects, expected_subjects)
def test_start(self):
expected_start = min([course_run.start for course_run in self.course_runs])
self.assertEqual(self.program.start, expected_start)
def test_price_ranges(self):
currency = Currency.objects.get(code='USD')
course_run = factories.CourseRunFactory()
factories.SeatFactory(type='audit', currency=currency, course_run=course_run, price=0)
factories.SeatFactory(type='credit', currency=currency, course_run=course_run, price=600)
factories.SeatFactory(type='verified', currency=currency, course_run=course_run, price=100)
applicable_seat_types = SeatType.objects.filter(slug__in=['credit', 'verified'])
program_type = factories.ProgramTypeFactory(name='XSeries', applicable_seat_types=applicable_seat_types)
program = factories.ProgramFactory(type=program_type, courses=[course_run.course])
expected_price_ranges = [{'currency': 'USD', 'min': Decimal(100), 'max': Decimal(600)}]
self.assertEqual(program.price_ranges, expected_price_ranges)
def test_instructors(self):
instructors = factories.PersonFactory.create_batch(2)
self.course_runs[0].instructors.add(instructors[0])
self.course_runs[1].instructors.add(instructors[1])
self.assertEqual(self.program.instructors, set(instructors))
class PersonSocialNetworkTests(TestCase):
......
cryptography==1.4
django==1.8.14
django-extensions==1.6.7
django-choices==1.4.3
django-compressor==2.0
django-extensions==1.6.7
django-filter==0.13.0
django-guardian==1.4.4
django-haystack==2.4.1
......
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