Commit 58665dd5 by christopher lee Committed by Christopher Lee

Add multilingual content for Subjects

parent a4994b7d
......@@ -181,7 +181,11 @@ class PersonFilter(filters.FilterSet):
class SubjectFilter(filters.FilterSet):
language_code = filters.CharFilter(method='_set_language')
def _set_language(self, queryset, _, language_code):
return queryset.language(language_code)
class Meta:
model = Subject
fields = ('slug', )
fields = ('slug', 'language_code')
......@@ -163,6 +163,9 @@ class FAQSerializer(serializers.ModelSerializer):
class SubjectSerializer(serializers.ModelSerializer):
"""Serializer for the ``Subject`` model."""
name = serializers.CharField(source='name_t')
subtitle = serializers.CharField(source='subtitle_t')
description = serializers.CharField(source='description_t')
@classmethod
def prefetch_queryset(cls):
......
......@@ -949,11 +949,11 @@ class SubjectSerializerTests(TestCase):
serializer = SubjectSerializer(subject)
expected = {
'name': subject.name,
'description': subject.description,
'name': subject.name_t,
'description': subject.description_t,
'banner_image_url': subject.banner_image_url,
'card_image_url': subject.card_image_url,
'subtitle': subject.subtitle,
'subtitle': subject.subtitle_t,
'slug': subject.slug,
'uuid': str(subject.uuid),
}
......
......@@ -3,6 +3,7 @@ from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from parler.admin import TranslatableAdmin
from course_discovery.apps.course_metadata.exceptions import (
MarketingSiteAPIClientException, MarketingSitePublisherException
......@@ -220,11 +221,13 @@ class OrganizationAdmin(admin.ModelAdmin):
@admin.register(Subject)
class SubjectAdmin(admin.ModelAdmin):
list_display = ('uuid', 'name', 'slug',)
class SubjectAdmin(TranslatableAdmin):
# These fields are excluded here because they will be removed in favor of the translated fields.
exclude = ('name', 'subtitle', 'description')
list_display = ('uuid', 'name_t', 'slug',)
list_filter = ('partner',)
readonly_fields = ('uuid',)
search_fields = ('uuid', 'name', 'slug',)
search_fields = ('uuid', 'name_t', 'slug',)
@admin.register(Person)
......
......@@ -143,15 +143,27 @@ class SubjectMarketingSiteDataLoader(AbstractMarketingSiteDataLoader):
slug = data['field_subject_url_slug']
defaults = {
'uuid': data['uuid'],
'name': data['title'],
'description': self.clean_html(data['body']['value']),
'subtitle': self.clean_html(data['field_subject_subtitle']['value']),
'name_t': data['title'],
'description_t': self.clean_html(data['body']['value']),
'subtitle_t': self.clean_html(data['field_subject_subtitle']['value']),
'card_image_url': self._get_nested_url(data.get('field_subject_card_image')),
# NOTE (CCB): This is not a typo. Yes, the banner image for subjects is in a field with xseries in the name.
'banner_image_url': self._get_nested_url(data.get('field_xseries_banner_image'))
}
subject, __ = Subject.objects.update_or_create(slug=slug, partner=self.partner, defaults=defaults)
# There is a bug with django-parler when using django's update_or_create() so we manually update or create.
try:
subject = Subject.objects.get(slug=slug, partner=self.partner)
for key, value in defaults.items():
setattr(subject, key, value)
subject.save()
except Subject.DoesNotExist:
new_values = {'slug': slug, 'partner': self.partner}
new_values.update(defaults)
subject = Subject(**new_values)
subject.save()
logger.info('Processed subject with slug [%s].', slug)
return subject
......
......@@ -146,9 +146,9 @@ class SubjectMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMix
subject = Subject.objects.get(slug=slug, partner=self.partner)
expected_values = {
'uuid': UUID(data['uuid']),
'name': data['title'],
'description': self.loader.clean_html(data['body']['value']),
'subtitle': self.loader.clean_html(data['field_subject_subtitle']['value']),
'name_t': data['title'],
'description_t': self.loader.clean_html(data['body']['value']),
'subtitle_t': self.loader.clean_html(data['field_subject_subtitle']['value']),
'card_image_url': data['field_subject_card_image']['url'],
'banner_image_url': data['field_xseries_banner_image']['url'],
}
......@@ -166,6 +166,28 @@ class SubjectMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMix
for datum in api_data:
self.assert_subject_loaded(datum)
@responses.activate
def test_ingest2(self):
self.mock_login_response()
api_data = self.mock_api()
for data in api_data:
subject_data = {
'uuid': UUID(data['uuid']),
'name_t': data['title'],
'description_t': self.loader.clean_html(data['body']['value']),
'subtitle_t': self.loader.clean_html(data['field_subject_subtitle']['value']),
'card_image_url': data['field_subject_card_image']['url'],
'banner_image_url': data['field_xseries_banner_image']['url'],
}
slug = data['field_subject_url_slug']
Subject.objects.create(slug=slug, partner=self.partner, **subject_data)
self.loader.ingest()
for datum in api_data:
self.assert_subject_loaded(datum)
class SchoolMarketingSiteDataLoaderTests(AbstractMarketingSiteDataLoaderTestMixin, TestCase):
loader_class = SchoolMarketingSiteDataLoader
......
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-09-19 19:27
from __future__ import unicode_literals
import django.db.models.deletion
import django_extensions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0059_auto_20171002_1705'),
]
operations = [
migrations.CreateModel(
name='SubjectTranslation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')),
('name_t', models.CharField(max_length=255)),
('subtitle_t', models.CharField(blank=True, max_length=255, null=True)),
('description_t', models.TextField(blank=True, null=True)),
('master',
models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations',
to='course_metadata.Subject')),
],
options={
'verbose_name': 'Subject model translations',
},
),
migrations.AlterField(
model_name='subject',
name='slug',
field=django_extensions.db.fields.AutoSlugField(blank=True, editable=False,
help_text='Leave this field blank to have the value generated automatically.',
populate_from='name_t'),
),
migrations.AlterUniqueTogether(
name='subject',
unique_together=set([('partner', 'slug'), ('partner', 'uuid')]),
),
migrations.AlterUniqueTogether(
name='subjecttranslation',
unique_together=set([('language_code', 'master')]),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-09-11 17:06
from __future__ import unicode_literals
import logging
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations
logger = logging.getLogger(__name__)
def forwards_func(apps, schema_editor):
Subject = apps.get_model('course_metadata', 'Subject')
SubjectTranslation = apps.get_model('course_metadata', 'SubjectTranslation')
for subject in Subject.objects.all():
SubjectTranslation.objects.create(
master_id=subject.pk,
language_code=settings.PARLER_DEFAULT_LANGUAGE_CODE,
name_t=subject.name,
subtitle_t=subject.subtitle,
description_t=subject.description
)
def backwards_func(apps, schema_editor):
Subject = apps.get_model('course_metadata', 'Subject')
SubjectTranslation = apps.get_model('course_metadata', 'SubjectTranslation')
for subject in Subject.objects.all():
try:
translation = SubjectTranslation.objects.filter(master_id=subject.pk)
subject.name = translation.name_t
subject.subtitle = translation.subtitle_t
subject.description = translation.description_t
subject.save() # Note this only calls Model.save()
except ObjectDoesNotExist:
# nothing to migrate
logger.exception('Migrating data from SubjectTranslation for master_id={} DoesNotExist'.format(subject.pk))
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0060_create_subjecttranslations_models'),
]
operations = [
migrations.RunPython(forwards_func, backwards_func),
]
......@@ -7,6 +7,7 @@ from uuid import uuid4
import pytz
import waffle
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models.query_utils import Q
from django.utils.functional import cached_property
......@@ -15,6 +16,7 @@ from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields import AutoSlugField
from django_extensions.db.models import TimeStampedModel
from haystack.query import SearchQuerySet
from parler.models import TranslatableModel, TranslatedFieldsModel
from solo.models import SingletonModel
from sortedm2m.fields import SortedManyToManyField
from stdimage.models import StdImageField
......@@ -111,7 +113,7 @@ class LevelType(AbstractNamedModel):
pass
class Subject(TimeStampedModel):
class Subject(TranslatableModel, TimeStampedModel):
""" Subject model. """
uuid = models.UUIDField(blank=False, null=False, default=uuid4, editable=False, verbose_name=_('UUID'))
name = models.CharField(max_length=255, blank=False, null=False)
......@@ -119,7 +121,7 @@ class Subject(TimeStampedModel):
description = models.TextField(blank=True, null=True)
banner_image_url = models.URLField(blank=True, null=True)
card_image_url = models.URLField(blank=True, null=True)
slug = AutoSlugField(populate_from='name', editable=True, blank=True,
slug = AutoSlugField(populate_from='name_t', editable=True, blank=True,
help_text=_('Leave this field blank to have the value generated automatically.'))
partner = models.ForeignKey(Partner)
......@@ -128,11 +130,31 @@ class Subject(TimeStampedModel):
class Meta:
unique_together = (
('partner', 'name'),
('partner', 'slug'),
('partner', 'uuid'),
)
def validate_unique(self, *args, **kwargs):
super(Subject, self).validate_unique(*args, **kwargs)
qs = Subject.objects.filter(partner=self.partner_id)
if qs.filter(translations__name_t=self.name_t).exists():
raise ValidationError({'name_t': ['Subject with this Name and Partner already exists', ]})
class SubjectTranslation(TranslatedFieldsModel):
master = models.ForeignKey(Subject, related_name='translations', null=True)
name_t = models.CharField(max_length=255, blank=False, null=False)
subtitle_t = models.CharField(max_length=255, blank=True, null=True)
description_t = models.TextField(blank=True, null=True)
def __str__(self):
return self.name_t
class Meta:
unique_together = ('language_code', 'master')
verbose_name = _('Subject model translations')
class Prerequisite(AbstractNamedModel):
""" Prerequisite model. """
......
......@@ -41,8 +41,8 @@ class SubjectFactory(factory.DjangoModelFactory):
class Meta:
model = Subject
name = FuzzyText()
description = FuzzyText()
name_t = FuzzyText() # TODO Switch to 'name' when 'name_t' is renamed.
description_t = FuzzyText()
banner_image_url = FuzzyURL()
card_image_url = FuzzyURL()
partner = factory.SubFactory(PartnerFactory)
......
......@@ -6,11 +6,13 @@ import ddt
import mock
from dateutil.parser import parse
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models.functions import Lower
from django.test import TestCase
from freezegun import freeze_time
from course_discovery.apps.api.tests.mixins import SiteMixin
from course_discovery.apps.core.models import Currency
from course_discovery.apps.core.tests.helpers import make_image_file
from course_discovery.apps.core.tests.mixins import ElasticsearchTestMixin
......@@ -18,8 +20,7 @@ from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.models import (
FAQ, AbstractMediaModel, AbstractNamedModel, AbstractValueModel, CorporateEndorsement, Course, CourseRun,
Endorsement, Seat, SeatType
)
Endorsement, Seat, SeatType, Subject)
from course_discovery.apps.course_metadata.publishers import (
CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher
)
......@@ -487,8 +488,8 @@ class ProgramTests(TestCase):
return factories.ProgramFactory(type=program_type, courses=[course_run.course])
def assert_one_click_purchase_ineligible_program(
self, end=None, enrollment_start=None, enrollment_end=None, seat_type=Seat.VERIFIED,
upgrade_deadline=None, one_click_purchase_enabled=True, excluded_course_runs=None, program_type=None
self, end=None, enrollment_start=None, enrollment_end=None, seat_type=Seat.VERIFIED,
upgrade_deadline=None, one_click_purchase_enabled=True, excluded_course_runs=None, program_type=None
):
course_run = factories.CourseRunFactory(
end=end, enrollment_start=enrollment_start, enrollment_end=enrollment_end
......@@ -1009,3 +1010,29 @@ class FAQTests(TestCase):
question = 'test question'
faq = FAQ.objects.create(question=question, answer='test')
self.assertEqual(str(faq), question)
class SubjectTests(SiteMixin, TestCase):
""" Tests of the Multilingual Subject model. """
def test_partner_name_t_uniqueness(self):
dummy_url = "http://www.example.com"
Subject.objects.create(
name="name1",
name_t="aaa",
partner_id=self.partner.id,
banner_image_url=dummy_url,
card_image_url=dummy_url)
invalid_subject = Subject(
name="name2",
name_t="aaa",
partner_id=self.partner.id,
banner_image_url=dummy_url,
card_image_url=dummy_url)
with self.assertRaises(ValidationError) as validation_error:
invalid_subject.full_clean()
self.assertEqual(
str(validation_error.exception),
"{'name_t': ['Subject with this Name and Partner already exists']}")
......@@ -3,7 +3,7 @@ import pytest
from django.apps import apps
from factory import DjangoModelFactory
from course_discovery.apps.course_metadata.models import DataLoaderConfig
from course_discovery.apps.course_metadata.models import DataLoaderConfig, SubjectTranslation
from course_discovery.apps.course_metadata.tests import factories
......@@ -25,7 +25,7 @@ class TestCacheInvalidation:
# connecting to. We want to test each of them.
for model in apps.get_app_config('course_metadata').get_models():
# Ignore models that aren't exposed by the API or are only used for testing.
if model == DataLoaderConfig or 'abstract' in model.__name__.lower():
if model in [DataLoaderConfig, SubjectTranslation] or 'abstract' in model.__name__.lower():
continue
factory = factory_map.get(model)
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-10-06 12:30+0500\n"
"POT-Creation-Date: 2017-10-06 17:23+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -286,6 +286,10 @@ msgid "Leave this field blank to have the value generated automatically."
msgstr ""
#: apps/course_metadata/models.py
msgid "Subject model translations"
msgstr ""
#: apps/course_metadata/models.py
msgid ""
"Logo to be displayed on certificates. If this logo is the same as "
"logo_image_url, copy and paste the same value to both fields."
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-10-06 12:30+0500\n"
"POT-Creation-Date: 2017-10-06 17:23+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-10-06 12:30+0500\n"
"POT-Creation-Date: 2017-10-06 17:23+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -339,6 +339,10 @@ msgstr ""
"ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α#"
#: apps/course_metadata/models.py
msgid "Subject model translations"
msgstr "Süßjéçt mödél tränslätïöns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: apps/course_metadata/models.py
msgid ""
"Logo to be displayed on certificates. If this logo is the same as "
"logo_image_url, copy and paste the same value to both fields."
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-10-06 12:30+0500\n"
"POT-Creation-Date: 2017-10-06 17:23+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......
......@@ -55,6 +55,7 @@ THIRD_PARTY_APPS = [
'taggit_serializer',
'solo',
'webpack_loader',
'parler',
]
PROJECT_APPS = [
......@@ -110,9 +111,21 @@ DATABASES = {
}
# Internationalization
# https://docs.djangoproject.com/en/dev/topics/i18n/
# See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code
# http://www.i18nguy.com/unicode/language-identifiers.html
LANGUAGE_CODE = 'en'
LANGUAGE_CODE = 'en-us'
PARLER_DEFAULT_LANGUAGE_CODE = LANGUAGE_CODE
PARLER_LANGUAGES = {
1: (
{'code': LANGUAGE_CODE, },
),
'default': {
'fallbacks': [PARLER_DEFAULT_LANGUAGE_CODE],
'hide_untranslated': False,
}
}
TIME_ZONE = 'UTC'
......
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