Commit f291977f by jaebradley Committed by Jae Bradley

implement topic model

fixed tests

ignore topic model

fix pylint

fix lint

add tests

rename

updated translations

fix translations

add newline at end of file

fake translations

update migration

fix import

fix flaky test
parent a46a3823
......@@ -13,7 +13,9 @@ from rest_framework.exceptions import NotFound, PermissionDenied
from course_discovery.apps.api.utils import cast2int
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import Course, CourseRun, Organization, Person, Program, Subject
from course_discovery.apps.course_metadata.models import (
Course, CourseRun, Organization, Person, Program, Subject, Topic
)
logger = logging.getLogger(__name__)
User = get_user_model()
......@@ -191,3 +193,14 @@ class SubjectFilter(filters.FilterSet):
class Meta:
model = Subject
fields = ('slug', 'language_code')
class TopicFilter(filters.FilterSet):
language_code = filters.CharFilter(method='_set_language')
def _set_language(self, queryset, _, language_code):
return queryset.language(language_code)
class Meta:
model = Topic
fields = ('slug', 'language_code')
......@@ -19,10 +19,11 @@ from course_discovery.apps.api.fields import ImageField, StdImageSerializerField
from course_discovery.apps.catalogs.models import Catalog
from course_discovery.apps.core.api_client.lms import LMSAPIClient
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.models import (FAQ, CorporateEndorsement, Course, CourseEntitlement,
CourseRun, Endorsement, Image, Organization, Person,
PersonSocialNetwork, PersonWork, Position, Prerequisite,
Program, ProgramType, Seat, SeatType, Subject, Video)
from course_discovery.apps.course_metadata.models import (
FAQ, CorporateEndorsement, Course, CourseEntitlement, CourseRun, Endorsement, Image, Organization, Person,
PersonSocialNetwork, PersonWork, Position, Prerequisite, Program, ProgramType, Seat, SeatType, Subject, Topic,
Video
)
from course_discovery.apps.course_metadata.search_indexes import CourseIndex, CourseRunIndex, ProgramIndex
User = get_user_model()
......@@ -1299,3 +1300,15 @@ class AggregateFacetSearchSerializer(BaseHaystackFacetSerializer):
CourseIndex: CourseFacetSerializer,
ProgramIndex: ProgramFacetSerializer,
}
class TopicSerializer(serializers.ModelSerializer):
"""Serializer for the ``Topic`` model."""
@classmethod
def prefetch_queryset(cls):
return Topic.objects.filter()
class Meta(object):
model = Topic
fields = ('name', 'subtitle', 'description', 'long_description', 'banner_image_url', 'slug', 'uuid')
......@@ -25,8 +25,8 @@ from course_discovery.apps.api.serializers import (
MinimalCourseRunSerializer, MinimalCourseSerializer, MinimalOrganizationSerializer, MinimalProgramCourseSerializer,
MinimalProgramSerializer, NestedProgramSerializer, OrganizationSerializer, PersonSerializer, PositionSerializer,
PrerequisiteSerializer, ProgramSearchSerializer, ProgramSerializer, ProgramTypeSerializer, SeatSerializer,
SubjectSerializer, TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer, VideoSerializer,
get_utm_source_for_user
SubjectSerializer, TopicSerializer, TypeaheadCourseRunSearchSerializer, TypeaheadProgramSearchSerializer,
VideoSerializer, get_utm_source_for_user
)
from course_discovery.apps.api.tests.mixins import SiteMixin
from course_discovery.apps.catalogs.tests.factories import CatalogFactory
......@@ -39,7 +39,7 @@ from course_discovery.apps.course_metadata.models import Course, CourseRun, Prog
from course_discovery.apps.course_metadata.tests.factories import (
CorporateEndorsementFactory, CourseFactory, CourseRunFactory, EndorsementFactory, ExpectedLearningItemFactory,
ImageFactory, JobOutlookItemFactory, OrganizationFactory, PersonFactory, PositionFactory, PrerequisiteFactory,
ProgramFactory, ProgramTypeFactory, SeatFactory, SeatTypeFactory, SubjectFactory, VideoFactory
ProgramFactory, ProgramTypeFactory, SeatFactory, SeatTypeFactory, SubjectFactory, TopicFactory, VideoFactory
)
from course_discovery.apps.ietf_language_tags.models import LanguageTag
......@@ -974,6 +974,24 @@ class SubjectSerializerTests(TestCase):
self.assertDictEqual(serializer.data, expected)
class TopicSerializerTests(TestCase):
def test_data(self):
topic = TopicFactory()
serializer = TopicSerializer(topic)
expected = {
'name': topic.name,
'description': topic.description,
'long_description': topic.long_description,
'banner_image_url': topic.banner_image_url,
'subtitle': topic.subtitle,
'slug': topic.slug,
'uuid': str(topic.uuid),
}
self.assertDictEqual(serializer.data, expected)
class ImageSerializerTests(TestCase):
def test_data(self):
image = ImageFactory()
......
......@@ -10,7 +10,8 @@ from rest_framework.test import APIRequestFactory
from course_discovery.apps.api.serializers import (
CatalogCourseSerializer, CatalogSerializer, CourseRunWithProgramsSerializer,
CourseWithProgramsSerializer, FlattenedCourseRunWithCourseSerializer, MinimalProgramSerializer,
OrganizationSerializer, PersonSerializer, ProgramSerializer, ProgramTypeSerializer, SubjectSerializer
OrganizationSerializer, PersonSerializer, ProgramSerializer, ProgramTypeSerializer, SubjectSerializer,
TopicSerializer
)
from course_discovery.apps.api.tests.mixins import SiteMixin
......@@ -70,6 +71,9 @@ class SerializationMixin:
def serialize_subject(self, subject, many=False, format=None, extra_context=None):
return self._serialize_object(SubjectSerializer, subject, many, format, extra_context)
def serialize_topic(self, topic, many=False, format=None, extra_context=None):
return self._serialize_object(TopicSerializer, topic, many, format, extra_context)
class OAuth2Mixin(object):
def generate_oauth2_token_header(self, user):
......
from django.urls import reverse
from course_discovery.apps.api.v1.tests.test_views.mixins import APITestCase, SerializationMixin
from course_discovery.apps.core.tests.factories import USER_PASSWORD, UserFactory
from course_discovery.apps.course_metadata.models import Topic
from course_discovery.apps.course_metadata.tests.factories import TopicFactory
class TopicViewSetTests(SerializationMixin, APITestCase):
list_path = reverse('api:v1:topic-list')
def setUp(self):
super(TopicViewSetTests, self).setUp()
self.user = UserFactory(is_staff=True, is_superuser=True)
self.client.login(username=self.user.username, password=USER_PASSWORD)
def test_authentication(self):
""" Verify the endpoint requires the user to be authenticated. """
response = self.client.get(self.list_path)
assert response.status_code == 200
self.client.logout()
response = self.client.get(self.list_path)
assert response.status_code == 403
def test_list(self):
""" Verify the endpoint returns a list of all topic. """
TopicFactory.create_batch(8)
expected = Topic.objects.all()
response = self.client.get(self.list_path)
assert response.status_code == 200
assert response.data['results'] == self.serialize_topic(expected, many=True)
def test_retrieve(self):
""" The request should return details for a single topic. """
topic = TopicFactory()
url = reverse('api:v1:topic-detail', kwargs={'uuid': topic.uuid})
response = self.client.get(url)
assert response.status_code == 200
assert response.data == self.serialize_topic(topic)
......@@ -13,6 +13,7 @@ from course_discovery.apps.api.v1.views.people import PersonViewSet
from course_discovery.apps.api.v1.views.program_types import ProgramTypeViewSet
from course_discovery.apps.api.v1.views.programs import ProgramViewSet
from course_discovery.apps.api.v1.views.subjects import SubjectViewSet
from course_discovery.apps.api.v1.views.topics import TopicViewSet
partners_router = routers.SimpleRouter()
partners_router.register(r'affiliate_window/catalogs', AffiliateWindowViewSet, base_name='affiliate_window')
......@@ -30,6 +31,7 @@ router.register(r'course_runs', CourseRunViewSet, base_name='course_run')
router.register(r'organizations', OrganizationViewSet, base_name='organization')
router.register(r'people', PersonViewSet, base_name='person')
router.register(r'subjects', SubjectViewSet, base_name='subject')
router.register(r'topics', TopicViewSet, base_name='topic')
router.register(r'programs', ProgramViewSet, base_name='program')
router.register(r'program_types', ProgramTypeViewSet, base_name='program_type')
router.register(r'search/all', search_views.AggregateSearchViewSet, base_name='search-all')
......
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from course_discovery.apps.api import filters, serializers
from course_discovery.apps.api.pagination import ProxiedPagination
# pylint: disable=no-member
class TopicViewSet(viewsets.ReadOnlyModelViewSet):
""" Topic resource. """
filter_backends = (DjangoFilterBackend,)
filter_class = filters.TopicFilter
lookup_field = 'uuid'
lookup_value_regex = '[0-9a-f-]+'
permission_classes = (IsAuthenticated,)
serializer_class = serializers.TopicSerializer
# Explicitly support PageNumberPagination and LimitOffsetPagination. Future
# versions of this API should only support the system default, PageNumberPagination.
pagination_class = ProxiedPagination
def get_queryset(self):
return serializers.TopicSerializer.prefetch_queryset()
def list(self, request, *args, **kwargs):
""" Retrieve a list of all topics. """
return super(TopicViewSet, self).list(request, *args, **kwargs)
def retrieve(self, request, *args, **kwargs):
""" Retrieve details for an topic. """
return super(TopicViewSet, self).retrieve(request, *args, **kwargs)
......@@ -230,6 +230,14 @@ class SubjectAdmin(TranslatableAdmin):
search_fields = ('uuid', 'name', 'slug',)
@admin.register(Topic)
class TopicAdmin(TranslatableAdmin):
list_display = ('uuid', 'name', 'slug',)
list_filter = ('partner',)
readonly_fields = ('uuid',)
search_fields = ('uuid', 'name', 'slug',)
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
inlines = (PositionInline, PersonWorkInline, PersonSocialNetworkInline)
......
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-11-28 19:45
from __future__ import unicode_literals
import uuid
import django.db.models.deletion
import django_extensions.db.fields
import parler.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0007_auto_20171004_1133'),
('course_metadata', '0070_auto_20171127_1057'),
]
operations = [
migrations.CreateModel(
name='Topic',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='UUID')),
('banner_image_url', models.URLField(blank=True, null=True)),
('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, help_text='Leave this field blank to have the value generated automatically.', populate_from='name')),
('partner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.Partner')),
],
bases=(parler.models.TranslatableModelMixin, models.Model),
),
migrations.CreateModel(
name='TopicTranslation',
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', models.CharField(max_length=255)),
('subtitle', models.CharField(blank=True, max_length=255, null=True)),
('description', models.TextField(blank=True, null=True)),
('long_description', models.TextField(blank=True, null=True)),
('master', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='translations', to='course_metadata.Topic')),
],
options={
'verbose_name': 'Topic model translations',
},
),
migrations.AlterUniqueTogether(
name='topictranslation',
unique_together=set([('language_code', 'master')]),
),
migrations.AlterUniqueTogether(
name='topic',
unique_together=set([('partner', 'uuid'), ('partner', 'slug')]),
),
]
......@@ -153,6 +153,44 @@ class SubjectTranslation(TranslatedFieldsModel):
verbose_name = _('Subject model translations')
class Topic(TranslatableModel, TimeStampedModel):
""" Topic model. """
uuid = models.UUIDField(blank=False, null=False, default=uuid4, editable=False, verbose_name=_('UUID'))
banner_image_url = models.URLField(blank=True, null=True)
slug = AutoSlugField(populate_from='name', editable=True, blank=True,
help_text=_('Leave this field blank to have the value generated automatically.'))
partner = models.ForeignKey(Partner)
def __str__(self):
return self.name
class Meta:
unique_together = (
('partner', 'slug'),
('partner', 'uuid'),
)
def validate_unique(self, *args, **kwargs):
super(Topic, self).validate_unique(*args, **kwargs)
qs = Topic.objects.filter(partner=self.partner_id)
if qs.filter(translations__name=self.name).exclude(pk=self.pk).exists():
raise ValidationError({'name': ['Topic with this Name and Partner already exists', ]})
class TopicTranslation(TranslatedFieldsModel):
master = models.ForeignKey(Topic, related_name='translations', null=True)
name = models.CharField(max_length=255, blank=False, null=False)
subtitle = models.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True)
long_description = models.TextField(blank=True, null=True)
class Meta:
unique_together = ('language_code', 'master')
verbose_name = _('Topic model translations')
class Prerequisite(AbstractNamedModel):
""" Prerequisite model. """
pass
......
......@@ -49,6 +49,18 @@ class SubjectFactory(factory.DjangoModelFactory):
uuid = factory.LazyFunction(uuid4)
class TopicFactory(factory.DjangoModelFactory):
class Meta:
model = Topic
name = FuzzyText()
description = FuzzyText()
long_description = FuzzyText()
banner_image_url = FuzzyURL()
partner = factory.SubFactory(PartnerFactory)
uuid = factory.LazyFunction(uuid4)
class LevelTypeFactory(AbstractNamedModelFactory):
class Meta:
model = LevelType
......
......@@ -21,7 +21,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, Subject
Endorsement, Seat, SeatType, Subject, Topic
)
from course_discovery.apps.course_metadata.publishers import (
CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher
......@@ -1185,3 +1185,32 @@ class SubjectTests(SiteMixin, TestCase):
self.assertEqual(
str(validation_error.exception),
"{'name': ['Subject with this Name and Partner already exists']}")
class TopicTests(SiteMixin, TestCase):
""" Tests of the Multilingual Topic (and TopicTranslation) model. """
def test_validate_unique(self):
topic = Topic.objects.create(
name="name1",
partner_id=self.partner.id,
banner_image_url="http://www.example.com",
)
self.assertIsNone(topic.full_clean())
duplicate_topic = Topic(
name="name1",
partner_id=self.partner.id,
banner_image_url="http://www.example.com",
)
with self.assertRaises(ValidationError) as validation_error:
duplicate_topic.full_clean()
self.assertEqual(
str(validation_error.exception),
"{'name': ['Topic with this Name and Partner already exists']}")
def test_str(self):
name = "name"
topic = Topic.objects.create(name=name, partner_id=self.partner.id)
self.assertEqual(topic.__str__(), name)
......@@ -3,7 +3,7 @@ import pytest
from django.apps import apps
from factory import DjangoModelFactory
from course_discovery.apps.course_metadata.models import DataLoaderConfig, SubjectTranslation
from course_discovery.apps.course_metadata.models import DataLoaderConfig, SubjectTranslation, TopicTranslation
from course_discovery.apps.course_metadata.tests import factories
......@@ -25,7 +25,8 @@ 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 in [DataLoaderConfig, SubjectTranslation] or 'abstract' in model.__name__.lower():
if model in [DataLoaderConfig, SubjectTranslation, TopicTranslation] \
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-11-28 09:26+0000\n"
"POT-Creation-Date: 2017-11-28 18:49+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"
......@@ -294,6 +294,10 @@ msgid "Subject model translations"
msgstr ""
#: apps/course_metadata/models.py
msgid "Topic model translations"
msgstr ""
#: apps/course_metadata/models.py
msgid ""
"Please do not use any spaces or special characters other than period, "
"underscore or hyphen. This key will be used in the course's course key."
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-11-28 09:26+0000\n"
"POT-Creation-Date: 2017-11-28 18:49+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-11-28 09:26+0000\n"
"POT-Creation-Date: 2017-11-28 18:49+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"
......@@ -347,6 +347,10 @@ msgid "Subject model translations"
msgstr "Süßjéçt mödél tränslätïöns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
#: apps/course_metadata/models.py
msgid "Topic model translations"
msgstr "Töpïç mödél tränslätïöns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢ση#"
#: apps/course_metadata/models.py
msgid ""
"Please do not use any spaces or special characters other than period, "
"underscore or hyphen. This key will be used in the course's course key."
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-11-28 09:26+0000\n"
"POT-Creation-Date: 2017-11-28 18:49+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"
......
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