Commit d3f6cb44 by Simon Chen Committed by GitHub

Merge pull request #296 from edx/schen/ECOM-5392

ECOM-5392 publish program data to marketing site to create or edit the program about page
parents 3e846e51 41f24b58
from django.contrib import admin
from django.contrib import admin, messages
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect
from simple_history.admin import SimpleHistoryAdmin
from course_discovery.apps.course_metadata.forms import ProgramAdminForm
from course_discovery.apps.course_metadata.models import * # pylint: disable=wildcard-import
from course_discovery.apps.course_metadata.publishers import ProgramPublisherException
class SeatInline(admin.TabularInline):
......@@ -84,6 +85,7 @@ class ProgramAdmin(admin.ModelAdmin):
'credit_backing_organizations'
)
fields += filter_horizontal
save_error = None
def custom_course_runs_display(self, obj):
return ", ".join([str(run) for run in obj.course_runs])
......@@ -91,13 +93,27 @@ class ProgramAdmin(admin.ModelAdmin):
custom_course_runs_display.short_description = "Included course runs"
def response_add(self, request, obj, post_url_continue=None):
return HttpResponseRedirect(reverse('admin_metadata:update_course_runs', kwargs={'pk': obj.pk}))
if self.save_error:
return self.response_post_save_add(request, obj)
else:
return HttpResponseRedirect(reverse('admin_metadata:update_course_runs', kwargs={'pk': obj.pk}))
def response_change(self, request, obj):
if '_continue'in request.POST or '_save' in request.POST:
return HttpResponseRedirect(reverse('admin_metadata:update_course_runs', kwargs={'pk': obj.pk}))
if self.save_error:
return self.response_post_save_change(request, obj)
else:
return HttpResponseRedirect(reverse('admin:course_metadata_program_add'))
if any(status in request.POST for status in ['_continue', '_save']):
return HttpResponseRedirect(reverse('admin_metadata:update_course_runs', kwargs={'pk': obj.pk}))
else:
return HttpResponseRedirect(reverse('admin:course_metadata_program_add'))
def save_model(self, request, obj, form, change):
try:
obj.save()
self.save_error = False
except ProgramPublisherException as ex:
messages.add_message(request, messages.ERROR, ex.message)
self.save_error = True
@admin.register(ProgramType)
......
......@@ -5,7 +5,7 @@ from urllib.parse import urljoin
from uuid import uuid4
import pytz
from django.db import models
from django.db import models, transaction
from django.db.models.query_utils import Q
from django.utils.translation import ugettext_lazy as _
from django_extensions.db.fields import AutoSlugField
......@@ -16,8 +16,10 @@ from simple_history.models import HistoricalRecords
from sortedm2m.fields import SortedManyToManyField
from stdimage.models import StdImageField
from taggit.managers import TaggableManager
import waffle
from course_discovery.apps.core.models import Currency, Partner
from course_discovery.apps.course_metadata.publishers import MarketingSitePublisher
from course_discovery.apps.course_metadata.query import CourseQuerySet, CourseRunQuerySet, ProgramQuerySet
from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath
from course_discovery.apps.course_metadata.utils import clean_query
......@@ -701,6 +703,28 @@ class Program(TimeStampedModel):
staff = itertools.chain.from_iterable(staff)
return set(staff)
@property
def is_active(self):
return self.status == self.Status.Active
def save(self, *args, **kwargs):
if waffle.switch_is_active('publish_program_to_marketing_site') and \
self.partner.has_marketing_site:
# Before save, get from database the existing data if exists
existing_program = None
if self.id:
existing_program = Program.objects.get(id=self.id)
# Pass existing data to the publisher so it can decide whether we should publish
publisher = MarketingSitePublisher(existing_program)
with transaction.atomic():
super(Program, self).save(*args, **kwargs)
# Once save complete, we need to update the marketing site
# So the marketing page for this program is automatically updated
publisher.publish_program(self)
else:
super(Program, self).save(*args, **kwargs)
class PersonSocialNetwork(AbstractSocialNetworkModel):
""" Person Social Network model. """
......
import json
import requests
from django.utils.functional import cached_property
class ProgramPublisherException(Exception):
def __init__(self, message):
super(ProgramPublisherException, self).__init__(message)
suffix = 'The program data has not been saved. Please check your marketing site configuration'
self.message = '{exception_msg} {suffix}'.format(exception_msg=message, suffix=suffix)
class MarketingSiteAPIClient(object):
"""
The marketing site API client we can use to communicate with the marketing site
"""
username = None
password = None
api_url = None
def __init__(self, marketing_site_api_username, marketing_site_api_password, api_url):
if not (marketing_site_api_username and marketing_site_api_password):
raise ProgramPublisherException('Marketing Site API credentials are not properly configured!')
self.username = marketing_site_api_username
self.password = marketing_site_api_password
self.api_url = api_url.strip('/')
@cached_property
def init_session(self):
# Login to set session cookies
session = requests.Session()
login_url = '{root}/user'.format(root=self.api_url)
login_data = {
'name': self.username,
'pass': self.password,
'form_id': 'user_login',
'op': 'Log in',
}
response = session.post(login_url, data=login_data)
expected_url = '{root}/users/{username}'.format(root=self.api_url, username=self.username)
if not (response.status_code == 200 and response.url == expected_url):
raise ProgramPublisherException('Marketing Site Login failed!')
return session
@cached_property
def api_session(self):
self.init_session.headers.update(self.headers)
return self.init_session
@cached_property
def csrf_token(self):
token_url = '{root}/restws/session/token'.format(root=self.api_url)
response = self.init_session.get(token_url)
if not response.status_code == 200:
raise ProgramPublisherException('Failed to retrieve Marketing Site CSRF token!')
token = response.content.decode('utf8')
return token
@cached_property
def user_id(self):
# Get a user ID
user_url = '{root}/user.json?name={username}'.format(root=self.api_url, username=self.username)
response = self.init_session.get(user_url)
if not response.status_code == 200:
raise ProgramPublisherException('Failed to retrieve Marketing site user details!')
user_id = response.json()['list'][0]['uid']
return user_id
@cached_property
def headers(self):
return {
'Content-Type': 'application/json',
'X-CSRF-Token': self.csrf_token,
}
class MarketingSitePublisher(object):
"""
This is the publisher that would publish the object data to marketing site
"""
data_before = None
def __init__(self, program_before=None):
if program_before:
self.data_before = {
'type': program_before.type,
'status': program_before.status,
'title': program_before.title,
}
def _get_node_data(self, program, user_id):
return {
'type': str(program.type).lower(),
'title': program.title,
'field_uuid': str(program.uuid),
'uuid': str(program.uuid),
'author': {
'id': user_id,
},
'status': 1 if program.is_active else 0
}
def _get_node_id(self, api_client, uuid):
node_url = '{root}/node.json?field_uuid={uuid}'.format(root=api_client.api_url, uuid=uuid)
response = api_client.api_session.get(node_url)
if response.status_code == 200:
found = response.json()
if found:
list_item = found.get('list')
if list_item:
return list_item[0]['nid']
def _edit_node(self, api_client, nid, node_data):
if node_data.get('uuid'):
# Drupal do not allow us to update the UUID field on node update
del node_data['uuid']
node_url = '{root}/node.json/{nid}'.format(root=api_client.api_url, nid=nid)
response = api_client.api_session.put(node_url, data=json.dumps(node_data))
if response.status_code != 200:
raise ProgramPublisherException("Marketing site page edit failed!")
def _create_node(self, api_client, node_data):
node_url = '{root}/node.json'.format(root=api_client.api_url)
response = api_client.api_session.post(node_url, data=json.dumps(node_data))
if response.status_code == 201:
return response.json()
else:
raise ProgramPublisherException("Marketing site page creation failed!")
def publish_program(self, program):
if not program.partner.has_marketing_site:
return
if not (program.partner.marketing_site_api_username and program.partner.marketing_site_api_password):
msg = 'Marketing Site API credentials are not properly configured for Partner [{partner}]!'.format(
partner=program.partner.short_code)
raise ProgramPublisherException(msg)
if self.data_before and \
self.data_before.get('title') == program.title and \
self.data_before.get('status') == program.status and \
self.data_before.get('type') == program.type:
# We don't need to publish to marketing site because
# nothing we care about has changed. This would save at least 4 network calls
return
api_client = MarketingSiteAPIClient(
program.partner.marketing_site_api_username,
program.partner.marketing_site_api_password,
program.partner.marketing_site_url_root
)
node_data = self._get_node_data(program, api_client.user_id)
nid = self._get_node_id(api_client, program.uuid)
if nid:
# We would like to edit the existing node
self._edit_node(api_client, nid, node_data)
else:
# We should create a new node
self._create_node(api_client, node_data)
from waffle.models import Switch
def toggle_switch(name, active):
"""
Activate or deactivate a feature switch.
The switch is created if it does not exist.
Arguments:
name (str): name of the switch to be toggled
active (bool): boolean indicating if the switch should be activated or deactivated
Returns:
Switch: Waffle Switch
"""
switch, __ = Switch.objects.get_or_create(name=name,
defaults={'active': active})
switch.active = active
switch.save()
return switch
import json
from factory.fuzzy import FuzzyText, FuzzyInteger
import responses
from course_discovery.apps.core.tests.utils import FuzzyUrlRoot
class MarketingSiteAPIClientTestMixin(object):
"""
The mixing to help mock the responses for marketing site API Client
"""
def setUp(self):
super(MarketingSiteAPIClientTestMixin, self).setUp()
self.username = FuzzyText().fuzz()
self.password = FuzzyText().fuzz()
self.api_root = FuzzyUrlRoot().fuzz()
self.csrf_token = FuzzyText().fuzz()
self.user_id = FuzzyInteger(1).fuzz()
def mock_login_response(self, status):
""" Mock the response of the marketing site login """
response_url = '{root}/users/{username}'.format(
root=self.api_root,
username=self.username
)
def request_callback(request): # pylint: disable=unused-argument
headers = {
'location': response_url
}
return (302, headers, None)
responses.add_callback(
responses.POST,
'{root}/user'.format(root=self.api_root),
callback=request_callback,
content_type='text/html',
)
responses.add(
responses.GET,
response_url,
body='',
content_type='text/html',
status=status
)
def mock_csrf_token_response(self, status):
responses.add(
responses.GET,
'{root}/restws/session/token'.format(root=self.api_root),
body=self.csrf_token,
content_type='text/html',
status=status
)
def mock_user_id_response(self, status):
data = {
'list': [{
'uid': self.user_id
}]
}
responses.add(
responses.GET,
'{root}/user.json?name={username}'.format(root=self.api_root, username=self.username),
body=json.dumps(data),
content_type='application/json',
status=status,
match_querystring=True
)
class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
"""
The mixing to help mock the responses for marketing site publisher
"""
def setUp(self):
super(MarketingSitePublisherTestMixin, self).setUp()
self.nid = FuzzyText().fuzz()
def mock_api_client(self, status):
self.mock_login_response(status)
self.mock_csrf_token_response(status)
self.mock_user_id_response(status)
def mock_node_retrieval(self, program_uuid, exists=True):
data = {}
status = 404
if exists:
data = {
'list': [{
'nid': self.nid
}]
}
status = 200
responses.add(
responses.GET,
'{root}/node.json?field_uuid={uuid}'.format(root=self.api_root, uuid=str(program_uuid)),
body=json.dumps(data),
content_type='application/json',
status=status,
match_querystring=True
)
def mock_node_edit(self, status):
responses.add(
responses.PUT,
'{root}/node.json/{nid}'.format(root=self.api_root, nid=self.nid),
body=json.dumps({}),
content_type='application/json',
status=status
)
def mock_node_create(self, response_data, status):
responses.add(
responses.POST,
'{root}/node.json'.format(root=self.api_root),
body=json.dumps(response_data),
content_type='application/json',
status=status
)
......@@ -6,7 +6,9 @@ from dateutil.parser import parse
from django.conf import settings
from django.db import IntegrityError
from django.test import TestCase
from factory.fuzzy import FuzzyText
from freezegun import freeze_time
import responses
from course_discovery.apps.core.models import Currency
from course_discovery.apps.core.tests.helpers import make_image_file
......@@ -14,10 +16,11 @@ from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.models import (
AbstractMediaModel, AbstractNamedModel, AbstractValueModel,
CorporateEndorsement, Course, CourseRun, Endorsement,
FAQ, SeatType
FAQ, SeatType, Program
)
from course_discovery.apps.course_metadata.tests import factories
from course_discovery.apps.course_metadata.tests import factories, toggle_switch
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ImageFactory
from course_discovery.apps.course_metadata.tests.mixins import MarketingSitePublisherTestMixin
from course_discovery.apps.ietf_language_tags.models import LanguageTag
......@@ -251,7 +254,8 @@ class AbstractValueModelTests(TestCase):
self.assertEqual(str(instance), value)
class ProgramTests(TestCase):
@ddt.ddt
class ProgramTests(MarketingSitePublisherTestMixin, TestCase):
"""Tests of the Program model."""
def setUp(self):
......@@ -369,6 +373,42 @@ class ProgramTests(TestCase):
program = self.create_program_with_seats()
self.assertEqual(program.seat_types, set(['credit', 'verified']))
@ddt.data(Program.Status.choices)
def test_is_active(self, status):
self.program.status = status
self.assertEqual(self.program.is_active, status == Program.Status.Active)
@responses.activate
def test_save_without_publish(self):
self.program.title = FuzzyText().fuzz()
self.program.save()
self.assertEqual(len(responses.calls), 0)
@responses.activate
def test_save_and_publish_success(self):
self.program.partner.marketing_site_url_root = self.api_root
self.program.partner.marketing_site_api_username = self.username
self.program.partner.marketing_site_api_password = self.password
self.program.save()
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_edit(200)
toggle_switch('publish_program_to_marketing_site', True)
self.program.title = FuzzyText().fuzz()
self.program.save()
self.assertEqual(len(responses.calls), 6)
toggle_switch('publish_program_to_marketing_site', False)
@responses.activate
def test_save_and_no_marketing_site(self):
self.program.partner.marketing_site_url_root = None
self.program.save()
toggle_switch('publish_program_to_marketing_site', True)
self.program.title = FuzzyText().fuzz()
self.program.save()
self.assertEqual(len(responses.calls), 0)
toggle_switch('publish_program_to_marketing_site', False)
class PersonSocialNetworkTests(TestCase):
"""Tests of the PersonSocialNetwork model."""
......
from django.test import TestCase
import responses
from course_discovery.apps.course_metadata.publishers import (
MarketingSiteAPIClient,
MarketingSitePublisher,
ProgramPublisherException,
)
from course_discovery.apps.course_metadata.tests.factories import ProgramFactory
from course_discovery.apps.course_metadata.tests.mixins import (
MarketingSiteAPIClientTestMixin,
MarketingSitePublisherTestMixin,
)
from course_discovery.apps.course_metadata.models import Program
class MarketingSiteAPIClientTests(MarketingSiteAPIClientTestMixin, TestCase):
"""
Unit test cases for MarketinSiteAPIClient
"""
def setUp(self):
super(MarketingSiteAPIClientTests, self).setUp()
self.api_client = MarketingSiteAPIClient(
self.username,
self.password,
self.api_root
)
@responses.activate
def test_init_session(self):
self.mock_login_response(200)
session = self.api_client.init_session
self.assertEqual(len(responses.calls), 2)
self.assertIsNotNone(session)
@responses.activate
def test_init_session_failed(self):
self.mock_login_response(500)
with self.assertRaises(ProgramPublisherException):
self.api_client.init_session # pylint: disable=pointless-statement
@responses.activate
def test_csrf_token(self):
self.mock_login_response(200)
self.mock_csrf_token_response(200)
csrf_token = self.api_client.csrf_token
self.assertEqual(len(responses.calls), 3)
self.assertEqual(self.csrf_token, csrf_token)
@responses.activate
def test_csrf_token_failed(self):
self.mock_login_response(200)
self.mock_csrf_token_response(500)
with self.assertRaises(ProgramPublisherException):
self.api_client.csrf_token # pylint: disable=pointless-statement
@responses.activate
def test_user_id(self):
self.mock_login_response(200)
self.mock_user_id_response(200)
user_id = self.api_client.user_id
self.assertEqual(len(responses.calls), 3)
self.assertEqual(self.user_id, user_id)
@responses.activate
def test_user_id_failed(self):
self.mock_login_response(200)
self.mock_user_id_response(500)
with self.assertRaises(ProgramPublisherException):
self.api_client.user_id # pylint: disable=pointless-statement
@responses.activate
def test_api_session(self):
self.mock_login_response(200)
self.mock_csrf_token_response(200)
api_session = self.api_client.api_session
self.assertEqual(len(responses.calls), 3)
self.assertIsNotNone(api_session)
self.assertEqual(api_session.headers.get('Content-Type'), 'application/json')
self.assertEqual(api_session.headers.get('X-CSRF-Token'), self.csrf_token)
@responses.activate
def test_api_session_failed(self):
self.mock_login_response(500)
self.mock_csrf_token_response(500)
with self.assertRaises(ProgramPublisherException):
self.api_client.api_session # pylint: disable=pointless-statement
class MarketingSitePublisherTests(MarketingSitePublisherTestMixin, TestCase):
"""
Unit test cases for the MarketingSitePublisher
"""
def setUp(self):
super(MarketingSitePublisherTests, self).setUp()
self.program = ProgramFactory()
self.program.partner.marketing_site_url_root = self.api_root
self.program.partner.marketing_site_api_username = self.username
self.program.partner.marketing_site_api_password = self.password
self.program.save() # pylint: disable=no-member
self.api_client = MarketingSiteAPIClient(
self.username,
self.password,
self.api_root
)
def test_get_node_data(self):
publisher = MarketingSitePublisher()
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access
expected = {
'type': str(self.program.type).lower(),
'title': self.program.title,
'field_uuid': str(self.program.uuid),
'uuid': str(self.program.uuid),
'author': {
'id': self.user_id,
},
'status': 1 if self.program.status == Program.Status.Active else 0
}
self.assertDictEqual(publish_data, expected)
@responses.activate
def test_get_node_id(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
publisher = MarketingSitePublisher()
node_id = publisher._get_node_id(self.api_client, self.program.uuid) # pylint: disable=protected-access
self.assertEqual(len(responses.calls), 4)
self.assertEqual(node_id, self.nid)
@responses.activate
def test_get_non_existent_node_id(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid, exists=False)
publisher = MarketingSitePublisher()
node_id = publisher._get_node_id(self.api_client, self.program.uuid) # pylint: disable=protected-access
self.assertIsNone(node_id)
@responses.activate
def test_edit_node(self):
self.mock_api_client(200)
self.mock_node_edit(200)
publisher = MarketingSitePublisher()
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access
publisher._edit_node(self.api_client, self.nid, publish_data) # pylint: disable=protected-access
self.assertEqual(len(responses.calls), 4)
@responses.activate
def test_edit_node_failed(self):
self.mock_api_client(200)
self.mock_node_edit(500)
publisher = MarketingSitePublisher()
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access
with self.assertRaises(ProgramPublisherException):
publisher._edit_node(self.api_client, self.nid, publish_data) # pylint: disable=protected-access
@responses.activate
def test_create_node(self):
self.mock_api_client(200)
expected = {
'list': [{
'nid': self.nid
}]
}
self.mock_node_create(expected, 201)
publisher = MarketingSitePublisher()
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access
data = publisher._create_node(self.api_client, publish_data) # pylint: disable=protected-access
self.assertEqual(data, expected)
@responses.activate
def test_create_node_failed(self):
self.mock_api_client(200)
self.mock_node_create({}, 500)
publisher = MarketingSitePublisher()
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access
with self.assertRaises(ProgramPublisherException):
publisher._create_node(self.api_client, publish_data) # pylint: disable=protected-access
@responses.activate
def test_publish_program_create(self):
self.mock_api_client(200)
expected = {
'list': [{
'nid': self.nid
}]
}
self.mock_node_retrieval(self.program.uuid, exists=False)
self.mock_node_create(expected, 201)
publisher = MarketingSitePublisher()
publisher.publish_program(self.program)
self.assertEqual(len(responses.calls), 6)
@responses.activate
def test_publish_program_edit(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_edit(200)
publisher = MarketingSitePublisher()
publisher.publish_program(self.program)
self.assertEqual(len(responses.calls), 6)
@responses.activate
def test_publish_modified_program(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_edit(200)
program_before = ProgramFactory()
publisher = MarketingSitePublisher(program_before)
publisher.publish_program(self.program)
self.assertEqual(len(responses.calls), 6)
@responses.activate
def test_publish_unmodified_program(self):
self.mock_api_client(200)
publisher = MarketingSitePublisher(self.program)
publisher.publish_program(self.program)
self.assertEqual(len(responses.calls), 0)
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def create_switch(apps, schema_editor):
"""Create and activate the publish_program_to_marketing_site switch if it does not already exist."""
Switch = apps.get_model('waffle', 'Switch')
Switch.objects.get_or_create(name='publish_program_to_marketing_site', defaults={'active': False})
def delete_switch(apps, schema_editor):
"""Delete the publish_program_to_marketing_site switch."""
Switch = apps.get_model('waffle', 'Switch')
Switch.objects.filter(name='publish_program_to_marketing_site').delete()
class Migration(migrations.Migration):
dependencies = [
('edx_catalog_extensions', '0002_convert_program_category_to_type'),
('waffle', '0001_initial'),
]
operations = [
migrations.RunPython(create_switch, reverse_code=delete_switch),
]
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