Commit 4acf759a by Renzo Lucioni

Publish course run status changes to the marketing site

Generalizes program publication code so that it can be applied to any model, including course runs.

LEARNER-419
parent aabf4fcd
......@@ -4,10 +4,17 @@ from django.http import HttpResponseRedirect
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from course_discovery.apps.course_metadata.exceptions import (
MarketingSiteAPIClientException, MarketingSitePublisherException
)
from course_discovery.apps.course_metadata.forms import CourseAdminForm, ProgramAdminForm
from course_discovery.apps.course_metadata.models import * # pylint: disable=wildcard-import
from course_discovery.apps.course_metadata.publishers import ProgramPublisherException
from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClientException
PUBLICATION_FAILURE_MSG_TPL = _(
'An error occurred while publishing the {model} to the marketing site. '
'Please try again. If the error persists, please contact the Engineering Team.'
)
class ProgramEligibilityFilter(admin.SimpleListFilter):
......@@ -101,6 +108,24 @@ class CourseRunAdmin(admin.ModelAdmin):
ordering = ('key',)
readonly_fields = ('uuid',)
search_fields = ('uuid', 'key', 'title_override', 'course__title', 'slug',)
save_error = False
def response_change(self, request, obj):
if self.save_error:
return self.response_post_save_change(request, obj)
return super().response_change(request, obj)
def save_model(self, request, obj, form, change):
try:
super().save_model(request, obj, form, change)
except (MarketingSitePublisherException, MarketingSiteAPIClientException):
self.save_error = True
logger.exception('An error occurred while publishing course run [%s] to the marketing site.', obj.key)
msg = PUBLICATION_FAILURE_MSG_TPL.format(model='course run') # pylint: disable=no-member
messages.add_message(request, messages.ERROR, msg)
@admin.register(Program)
......@@ -158,15 +183,14 @@ class ProgramAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change):
try:
super().save_model(request, obj, form, change)
self.save_error = False
except (ProgramPublisherException, MarketingSiteAPIClientException):
# TODO Redirect the user back to the form so that he/she can try again.
logger.exception('An error occurred while publishing the program [%s] to the marketing site.', obj.uuid)
msg = _('An error occurred while publishing the program to the marketing site. Please try again. '
'If the error persists, please contact the Engineering Team.')
messages.add_message(request, messages.ERROR, msg)
except (MarketingSitePublisherException, MarketingSiteAPIClientException):
self.save_error = True
logger.exception('An error occurred while publishing program [%s] to the marketing site.', obj.uuid)
msg = PUBLICATION_FAILURE_MSG_TPL.format(model='program') # pylint: disable=no-member
messages.add_message(request, messages.ERROR, msg)
class Media:
js = ('bower_components/jquery-ui/ui/minified/jquery-ui.min.js',
'js/sortable_select.js')
......
class MarketingSiteAPIClientException(Exception):
""" The exception thrown from MarketingSiteAPIClient """
pass
class ProgramPublisherException(Exception):
""" The exception thrown during the program publishing process to marketing site """
class MarketingSitePublisherException(Exception):
pass
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 AliasCreateError(MarketingSitePublisherException):
pass
class AliasDeleteError(MarketingSitePublisherException):
pass
class FormRetrievalError(MarketingSitePublisherException):
pass
class NodeCreateError(MarketingSitePublisherException):
pass
class NodeDeleteError(MarketingSitePublisherException):
pass
class NodeEditError(MarketingSitePublisherException):
pass
class NodeLookupError(MarketingSitePublisherException):
pass
class PersonToMarketingException(Exception):
......
......@@ -94,8 +94,8 @@ class CourseRunSelectionForm(forms.ModelForm):
)
query_set = [course.pk for course in instance.courses.all()]
self.fields["excluded_course_runs"].widget = forms.widgets.CheckboxSelectMultiple()
self.fields["excluded_course_runs"].help_text = ""
self.fields['excluded_course_runs'].widget = forms.widgets.CheckboxSelectMultiple()
self.fields['excluded_course_runs'].help_text = ''
self.fields['excluded_course_runs'].queryset = CourseRun.objects.filter(
course__id__in=query_set
)
......
from django.db import migrations
SWITCH = 'publish_course_runs_to_marketing_site'
def create_switch(apps, schema_editor):
"""Create the publish_course_runs_to_marketing_site switch."""
Switch = apps.get_model('waffle', 'Switch')
Switch.objects.get_or_create(name=SWITCH, defaults={'active': False})
def delete_switch(apps, schema_editor):
"""Delete the publish_course_runs_to_marketing_site switch."""
Switch = apps.get_model('waffle', 'Switch')
Switch.objects.filter(name=SWITCH).delete()
class Migration(migrations.Migration):
dependencies = [
('course_metadata', '0051_program_one_click_purchase_enabled'),
('waffle', '0001_initial'),
]
operations = [
migrations.RunPython(create_switch, reverse_code=delete_switch),
]
......@@ -23,7 +23,10 @@ from taggit_autosuggest.managers import TaggableManager
from course_discovery.apps.core.models import Currency, Partner
from course_discovery.apps.course_metadata.choices import CourseRunPacing, CourseRunStatus, ProgramStatus, ReportingType
from course_discovery.apps.course_metadata.publishers import MarketingSitePublisher
from course_discovery.apps.course_metadata.publishers import (
CourseRunMarketingSitePublisher,
ProgramMarketingSitePublisher
)
from course_discovery.apps.course_metadata.query import CourseQuerySet, CourseRunQuerySet, ProgramQuerySet
from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath, clean_query, custom_render_variations
from course_discovery.apps.ietf_language_tags.models import LanguageTag
......@@ -337,7 +340,6 @@ class Course(TimeStampedModel):
class CourseRun(TimeStampedModel):
""" CourseRun model. """
uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID'))
course = models.ForeignKey(Course, related_name='course_runs')
key = models.CharField(max_length=255, unique=True)
......@@ -595,6 +597,22 @@ class CourseRun(TimeStampedModel):
def __str__(self):
return '{key}: {title}'.format(key=self.key, title=self.title)
def save(self, *args, **kwargs):
is_publishable = (
self.course.partner.has_marketing_site and
waffle.switch_is_active('publish_course_runs_to_marketing_site')
)
if is_publishable:
publisher = CourseRunMarketingSitePublisher(self.course.partner)
previous_obj = CourseRun.objects.get(id=self.id) if self.id else None
with transaction.atomic():
super(CourseRun, self).save(*args, **kwargs)
publisher.publish_obj(self, previous_obj=previous_obj)
else:
super(CourseRun, self).save(*args, **kwargs)
class SeatType(TimeStampedModel):
name = models.CharField(max_length=64, unique=True)
......@@ -973,19 +991,18 @@ class Program(TimeStampedModel):
return self.status == ProgramStatus.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)
is_publishable = (
self.partner.has_marketing_site and
waffle.switch_is_active('publish_program_to_marketing_site')
)
if is_publishable:
publisher = ProgramMarketingSitePublisher(self.partner)
previous_obj = Program.objects.get(id=self.id) if self.id else None
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)
publisher.publish_obj(self, previous_obj=previous_obj)
else:
super(Program, self).save(*args, **kwargs)
......
import json
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from django.utils.functional import cached_property
from course_discovery.apps.course_metadata.exceptions import ProgramPublisherException
from course_discovery.apps.course_metadata.choices import CourseRunStatus
from course_discovery.apps.course_metadata.exceptions import (
AliasCreateError,
AliasDeleteError,
FormRetrievalError,
NodeCreateError,
NodeDeleteError,
NodeEditError,
NodeLookupError
)
from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClient
class MarketingSitePublisher(object):
class BaseMarketingSitePublisher:
"""
This is the publisher that would publish the object data to marketing site
Utility for publishing data to a Drupal marketing site.
Arguments:
partner (apps.core.models.Partner): Partner instance containing information
about the marketing site to which to publish.
"""
program_before = None
unique_field = None
node_lookup_field = None
def __init__(self, program_before=None):
if program_before:
self.program_before = program_before
def __init__(self, partner):
self.partner = partner
def _get_api_client(self, program):
if not program.partner.has_marketing_site:
return
self.client = MarketingSiteAPIClient(
self.partner.marketing_site_api_username,
self.partner.marketing_site_api_password,
self.partner.marketing_site_url_root
)
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)
self.node_api_base = urljoin(self.client.api_url, '/node.json')
if program.type.name not in ['MicroMasters', 'Professional Certificate']:
# We do not publish programs that are not MicroMasters or Professional Certificate to the Marketing Site
return
def publish_obj(self, obj, previous_obj=None):
"""
Update or create a Drupal node corresponding to the given model instance.
fields_that_trigger_publish = ['title', 'status', 'type', 'marketing_slug']
if self.program_before and \
all(getattr(self.program_before, key) == getattr(program, key) for key in fields_that_trigger_publish):
# 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
Arguments:
obj (django.db.models.Model): Model instance to be published.
return MarketingSiteAPIClient(
program.partner.marketing_site_api_username,
program.partner.marketing_site_api_password,
program.partner.marketing_site_url_root
)
Keyword Arguments:
previous_obj (CourseRun): Model instance representing the previous
state of the model being changed. Inspected to determine if publication
is necessary. May not exist if the model instance is being saved
for the first time.
"""
raise NotImplementedError
def delete_obj(self, obj):
"""
Delete a Drupal node corresponding to the given model instance.
Arguments:
obj (django.db.models.Model): Model instance to be deleted.
"""
node_id = self.node_id(obj)
def _get_node_data(self, program, user_id):
self.delete_node(node_id)
def serialize_obj(self, obj):
"""
Serialize a model instance to a representation that can be written to Drupal.
Arguments:
obj (django.db.models.Model): Model instance to be published.
Returns:
dict: Data to PUT to the Drupal API.
"""
return {
'type': str(program.type).lower().replace(' ', '_'),
'title': program.title,
'field_uuid': str(program.uuid),
'uuid': str(program.uuid),
'author': {
'id': user_id,
},
'status': 1 if program.is_active else 0
self.node_lookup_field: str(getattr(obj, self.unique_field)),
'author': {'id': self.client.user_id},
}
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)
def node_id(self, obj):
"""
Find the ID of the node we want to publish to, if it exists.
Arguments:
obj (django.db.models.Model): Model instance to be published.
Returns:
str: The node ID.
Raises:
NodeLookupError: If node lookup fails.
"""
params = {
self.node_lookup_field: getattr(obj, self.unique_field),
}
response = self.client.api_session.get(self.node_api_base, params=params)
if response.status_code == 200:
list_item = response.json().get('list')
if list_item:
return list_item[0]['nid']
return response.json()['list'][0]['nid']
else:
raise NodeLookupError
def _edit_node(self, api_client, node_id, node_data):
# Drupal does not allow us to update the UUID field on node update
node_data.pop('uuid', None)
node_url = '{root}/node.json/{node_id}'.format(root=api_client.api_url, node_id=node_id)
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, node_data):
"""
Create a Drupal node.
Arguments:
node_data (dict): Data to POST to Drupal for node creation.
Returns:
str: The ID of the created node.
Raises:
NodeCreateError: If the POST to Drupal fails.
"""
node_data = json.dumps(node_data)
response = self.client.api_session.post(self.node_api_base, data=node_data)
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:
response_json = response.json()
return response_json['id']
return response.json()['id']
else:
raise ProgramPublisherException("Marketing site page creation failed!")
raise NodeCreateError
def edit_node(self, node_id, node_data):
"""
Edit a Drupal node.
def _delete_node(self, api_client, node_id):
node_url = '{root}/node.json/{node_id}'.format(root=api_client.api_url, node_id=node_id)
api_client.api_session.delete(node_url)
Arguments:
node_id (str): ID of the node to edit.
node_data (dict): Fields to overwrite on the node.
Raises:
NodeEditError: If the PUT to Drupal fails.
"""
node_url = '{base}/{node_id}'.format(base=self.node_api_base, node_id=node_id)
node_data = json.dumps(node_data)
response = self.client.api_session.put(node_url, data=node_data)
def _get_form_build_id_and_form_token(self, api_client, url):
form_attributes = {}
response = api_client.api_session.get(url)
if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias form retrieval failed!')
form = BeautifulSoup(response.text, 'html.parser')
for field in ('form_build_id', 'form_token'):
form_attributes[field] = form.find('input', {'name': field}).get('value')
return form_attributes
def _get_delete_alias_url(self, api_client, url):
response = api_client.api_session.get(url)
raise NodeEditError
def delete_node(self, node_id):
"""
Delete a Drupal node.
Arguments:
node_id (str): ID of the node to delete.
Raises:
NodeDeleteError: If the DELETE to Drupal fails.
"""
node_url = '{base}/{node_id}'.format(base=self.node_api_base, node_id=node_id)
response = self.client.api_session.delete(node_url)
if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias form retrieval failed!')
form = BeautifulSoup(response.text, 'html.parser')
delete_element = form.select('.delete.last a')
return delete_element[0].get('href') if delete_element else None
raise NodeDeleteError
def _get_headers(self):
headers = {
'content-type': 'application/x-www-form-urlencoded'
class CourseRunMarketingSitePublisher(BaseMarketingSitePublisher):
"""
Utility for publishing course run data to a Drupal marketing site.
"""
unique_field = 'key'
node_lookup_field = 'field_course_id'
def publish_obj(self, obj, previous_obj=None):
"""
Publish a CourseRun to the marketing site.
Publication only occurs if the CourseRun's status has changed.
Arguments:
obj (CourseRun): CourseRun instance to be published.
Keyword Arguments:
previous_obj (CourseRun): Previous state of the course run. Inspected to
determine if publication is necessary. May not exist if the course run
is being saved for the first time.
"""
if previous_obj and obj.status != previous_obj.status:
node_id = self.node_id(obj)
node_data = self.serialize_obj(obj)
self.edit_node(node_id, node_data)
def serialize_obj(self, obj):
"""
Serialize the CourseRun instance to be published.
Arguments:
obj (CourseRun): CourseRun instance to be published.
Returns:
dict: Data to PUT to the Drupal API.
"""
data = super().serialize_obj(obj)
return {
**data,
'status': 1 if obj.status == CourseRunStatus.Published else 0,
}
return headers
def _make_alias(self, program):
alias = '{program_type_slug}/{slug}'.format(program_type_slug=program.type.slug, slug=program.marketing_slug)
return alias
def _add_alias(self, api_client, node_id, alias, before_slug):
base_aliases_url = '{root}/admin/config/search/path'.format(root=api_client.api_url)
add_aliases_url = '{url}/add'.format(url=base_aliases_url)
node_url = 'node/{node_id}'.format(node_id=node_id)
class ProgramMarketingSitePublisher(BaseMarketingSitePublisher):
"""
Utility for publishing program data to a Drupal marketing site.
"""
unique_field = 'uuid'
node_lookup_field = 'field_uuid'
def __init__(self, partner):
super().__init__(partner)
self.alias_api_base = urljoin(self.client.api_url, '/admin/config/search/path')
self.alias_add_url = '{}/add'.format(self.alias_api_base)
def publish_obj(self, obj, previous_obj=None):
"""
Publish a Program to the marketing site.
Arguments:
obj (Program): Program instance to be published.
Keyword Arguments:
previous_obj (Program): Previous state of the program. Inspected to
determine if publication is necessary. May not exist if the program
is being saved for the first time.
"""
if obj.type.name in {'MicroMasters', 'Professional Certificate'}:
node_data = self.serialize_obj(obj)
node_id = None
if not previous_obj:
node_id = self.create_node(node_data)
else:
trigger_fields = (
'marketing_slug',
'status',
'title',
'type',
)
if any(getattr(obj, field) != getattr(previous_obj, field) for field in trigger_fields):
node_id = self.node_id(obj)
# Drupal does not allow modification of the UUID field.
node_data.pop('uuid', None)
self.edit_node(node_id, node_data)
if node_id:
self.update_node_alias(obj, node_id, previous_obj)
def serialize_obj(self, obj):
"""
Serialize the Program instance to be published.
Arguments:
obj (Program): Program instance to be published.
Returns:
dict: Data to PUT to the Drupal API.
"""
data = super().serialize_obj(obj)
return {
**data,
'status': 1 if obj.is_active else 0,
'title': obj.title,
'type': str(obj.type).lower().replace(' ', '_'),
'uuid': str(obj.uuid),
}
def update_node_alias(self, obj, node_id, previous_obj):
"""
Update alias of the Drupal node corresponding to the given object.
Arguments:
obj (Program): Program instance to be published.
node_id (str): The ID of the node corresponding to the object.
previous_obj (Program): Previous state of the program. May be None.
Raises:
AliasCreateError: If there's a problem creating a new alias.
AliasDeleteError: If there's a problem deleting an old alias.
"""
new_alias = self.alias(obj)
previous_alias = self.alias(previous_obj) if previous_obj else None
if new_alias != previous_alias:
alias_add_url = '{}/add'.format(self.alias_api_base)
headers = {
'content-type': 'application/x-www-form-urlencoded'
}
data = {
'source': node_url,
'alias': alias,
**self.alias_form_inputs,
'alias': new_alias,
'form_id': 'path_admin_form',
'op': 'Save'
'op': 'Save',
'source': 'node/{}'.format(node_id),
}
form_attributes = self._get_form_build_id_and_form_token(api_client, add_aliases_url)
data.update(form_attributes)
headers = self._get_headers()
response = api_client.api_session.post(add_aliases_url, headers=headers, data=data)
response = self.client.api_session.post(alias_add_url, headers=headers, data=data)
if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias creation failed!')
# Delete old alias after saving new one
if before_slug:
list_aliases_url = '{url}/list/{slug}'.format(url=base_aliases_url, slug=before_slug)
delete_alias_url = self._get_delete_alias_url(api_client, list_aliases_url)
if delete_alias_url:
delete_alias_url = '{root}{url}'.format(root=api_client.api_url, url=delete_alias_url)
raise AliasCreateError
# Delete old alias after saving the new one.
if previous_obj:
alias_list_url = '{base}/list/{slug}'.format(
base=self.alias_api_base,
slug=previous_obj.marketing_slug
)
alias_delete_path = self.alias_delete_path(alias_list_url)
if alias_delete_path:
alias_delete_url = '{base}/{path}'.format(
base=self.client.api_url,
path=alias_delete_path.strip('/')
)
data = {
"confirm": 1,
"form_id": "path_admin_delete_confirm",
"op": "Confirm"
**self.alias_form_inputs,
'confirm': 1,
'form_id': 'path_admin_delete_confirm',
'op': 'Confirm',
}
form_attributes = self._get_form_build_id_and_form_token(api_client, delete_alias_url)
data.update(form_attributes)
response = api_client.api_session.post(delete_alias_url, headers=headers, data=data)
response = self.client.api_session.post(alias_delete_url, headers=headers, data=data)
if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias deletion failed!')
raise AliasDeleteError
def publish_program(self, program):
api_client = self._get_api_client(program)
if api_client:
node_data = self._get_node_data(program, api_client.user_id)
node_id = self._get_node_id(api_client, program.uuid)
if node_id:
# We would like to edit the existing node
self._edit_node(api_client, node_id, node_data)
else:
# We should create a new node
node_id = self._create_node(api_client, node_data)
before_alias = self._make_alias(self.program_before) if self.program_before else None
new_alias = self._make_alias(program)
before_slug = self.program_before.marketing_slug if self.program_before else None
if not self.program_before or (before_alias != new_alias):
self._add_alias(api_client, node_id, new_alias, before_slug)
def delete_program(self, program):
api_client = self._get_api_client(program)
if api_client:
node_id = self._get_node_id(api_client, program.uuid)
if node_id:
self._delete_node(api_client, node_id)
def alias(self, obj):
return '{type}/{slug}'.format(type=obj.type.slug, slug=obj.marketing_slug)
@cached_property
def alias_form_inputs(self):
"""
Scrape input values from the form used to modify Drupal aliases.
Raises:
FormRetrievalError: If there's a problem getting the form from Drupal.
"""
response = self.client.api_session.get(self.alias_add_url)
if response.status_code != 200:
raise FormRetrievalError
html = BeautifulSoup(response.text, 'html.parser')
return {
field: html.find('input', {'name': field}).get('value')
for field in ('form_build_id', 'form_token')
}
def alias_delete_path(self, url):
"""
Scrape the path to which we need to POST to delete an alias from the form
used to modify aliases.
Raises:
FormRetrievalError: If there's a problem getting the form from Drupal.
"""
response = self.client.api_session.get(url)
if response.status_code != 200:
raise FormRetrievalError
html = BeautifulSoup(response.text, 'html.parser')
delete_element = html.select('.delete.last a')
return delete_element[0].get('href') if delete_element else None
......@@ -3,12 +3,16 @@ from django.db.models.signals import pre_delete
from django.dispatch import receiver
from course_discovery.apps.course_metadata.models import Program
from course_discovery.apps.course_metadata.publishers import MarketingSitePublisher
from course_discovery.apps.course_metadata.publishers import ProgramMarketingSitePublisher
@receiver(pre_delete, sender=Program)
def delete_program(sender, instance, **kwargs): # pylint: disable=unused-argument
if waffle.switch_is_active('publish_program_to_marketing_site') and \
instance.partner.has_marketing_site:
publisher = MarketingSitePublisher()
publisher.delete_program(instance)
is_publishable = (
instance.partner.has_marketing_site and
waffle.switch_is_active('publish_program_to_marketing_site')
)
if is_publishable:
publisher = ProgramMarketingSitePublisher(instance.partner)
publisher.delete_obj(instance)
import json
import random
import urllib
import responses
......@@ -77,49 +78,46 @@ class MarketingSiteAPIClientTestMixin(TestCase):
class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
"""
The mixing to help mock the responses for marketing site publisher
Mixin for mocking Drupal responses when testing marketing site publishers.
"""
def setUp(self):
super(MarketingSitePublisherTestMixin, self).setUp()
self.node_id = FuzzyText().fuzz()
self.node_id = str(random.randint(1, 1000))
def mock_api_client(self, status):
def mock_api_client(self, status=200):
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 = 200
def mock_node_retrieval(self, node_lookup_field, node_lookup_value, exists=True, status=200):
url = '{root}/node.json?{node_lookup_field}={node_lookup_value}'.format(
root=self.api_root,
node_lookup_field=node_lookup_field,
node_lookup_value=node_lookup_value,
)
data = {
'list': []
'list': [{'nid': self.node_id}] if exists else []
}
if exists:
data['list'] = [{
'nid': self.node_id
}]
responses.add(
responses.GET,
'{root}/node.json?field_uuid={uuid}'.format(root=self.api_root, uuid=str(program_uuid)),
url,
body=json.dumps(data),
content_type='application/json',
status=status,
match_querystring=True
)
def mock_add_alias(self, status=200):
def mock_add_alias(self, alias=None, status=200):
node_url = 'node/{node_id}'.format(node_id=self.node_id)
alias = '{program_type}/{slug}'.format(
program_type=self.program.type.name.lower(),
slug=self.program.marketing_slug
)
data = {
'source': node_url,
'alias': alias,
'form_id': 'path_admin_form',
'op': 'Save'
}
responses.add(
responses.POST,
'{root}/admin/config/search/path/add'.format(root=self.api_root),
......@@ -129,13 +127,14 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
def mock_delete_alias(self, status=200):
data = {
"confirm": 1,
"form_id": "path_admin_delete_confirm",
"op": "Confirm"
'confirm': 1,
'form_id': 'path_admin_delete_confirm',
'op': 'Confirm'
}
responses.add(
responses.POST,
'{root}/foo'.format(root=self.api_root),
'{root}/admin/config/search/path/delete/foo'.format(root=self.api_root),
body=urllib.parse.urlencode(data),
status=status
)
......@@ -154,12 +153,15 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
responses.GET,
'{root}/admin/config/search/path/list/{alias}'.format(root=self.api_root, alias=alias),
status=status,
body='<li class="delete last"><a href="/admin/config/search/path/delete/bar"></a></li>'
body='<li class="delete last"><a href="/admin/config/search/path/delete/foo"></a></li>'
)
def mock_node_create(self, response_data, status):
responses.add(
responses.POST,
'{root}/admin/config/search/path/delete/bar'.format(root=self.api_root),
'{root}/node.json'.format(root=self.api_root),
body=json.dumps(response_data),
content_type='application/json',
status=status
)
......@@ -172,15 +174,6 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
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
)
def mock_node_delete(self, status):
responses.add(
responses.DELETE,
......
......@@ -4,13 +4,11 @@ from decimal import Decimal
import ddt
import mock
import responses
from dateutil.parser import parse
from django.conf import settings
from django.db import IntegrityError
from django.db.models.functions import Lower
from django.test import TestCase
from factory.fuzzy import FuzzyText
from freezegun import freeze_time
from course_discovery.apps.core.models import Currency
......@@ -20,12 +18,13 @@ from course_discovery.apps.core.utils import SearchQuerySetWrapper
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import (
FAQ, AbstractMediaModel, AbstractNamedModel, AbstractValueModel, CorporateEndorsement, Course, CourseRun,
Endorsement, ProgramType, Seat, SeatType
Endorsement, Seat, SeatType
)
from course_discovery.apps.course_metadata.publishers import (
CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher
)
from course_discovery.apps.course_metadata.publishers import MarketingSitePublisher
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
......@@ -257,7 +256,7 @@ class CourseRunTests(TestCase):
@ddt.unpack
def test_get_paid_seat_enrollment_end(self, seat_config, course_end, course_enrollment_end, expected_result):
"""
Verify that paid_seat_enrollment_end returns the latest possible date for which an unerolled user may
Verify that paid_seat_enrollment_end returns the latest possible date for which an unenrolled user may
enroll and purchase an upgrade for the CourseRun or None if date unknown or paid Seats are not available.
"""
end = parse(course_end) if course_end else None
......@@ -270,6 +269,43 @@ class CourseRunTests(TestCase):
expected_result = parse(expected_result) if expected_result else None
self.assertEqual(course_run.get_paid_seat_enrollment_end(), expected_result)
def test_publication_disabled(self):
"""
Verify that the publisher is not initialized when publication is disabled.
"""
toggle_switch('publish_course_runs_to_marketing_site', active=False)
with mock.patch.object(CourseRunMarketingSitePublisher, '__init__') as mock_init:
self.course_run.save()
self.course_run.delete()
assert mock_init.call_count == 0
toggle_switch('publish_course_runs_to_marketing_site')
self.course_run.course.partner.marketing_site_url_root = ''
self.course_run.course.partner.save()
with mock.patch.object(CourseRunMarketingSitePublisher, '__init__') as mock_init:
self.course_run.save()
self.course_run.delete()
assert mock_init.call_count == 0
def test_publication_enabled(self):
"""
Verify that the publisher is called when publication is enabled.
"""
toggle_switch('publish_course_runs_to_marketing_site')
with mock.patch.object(CourseRunMarketingSitePublisher, 'publish_obj', return_value=None) as mock_publish_obj:
self.course_run.save()
assert mock_publish_obj.called
with mock.patch.object(CourseRunMarketingSitePublisher, 'delete_obj', return_value=None) as mock_delete_obj:
self.course_run.delete()
# We don't want to delete course run nodes when CourseRuns are deleted.
assert not mock_delete_obj.called
class OrganizationTests(TestCase):
""" Tests for the `Organization` model. """
......@@ -390,7 +426,7 @@ class AbstractValueModelTests(TestCase):
@ddt.ddt
class ProgramTests(MarketingSitePublisherTestMixin):
class ProgramTests(TestCase):
"""Tests of the Program model."""
def setUp(self):
......@@ -737,77 +773,41 @@ class ProgramTests(MarketingSitePublisherTestMixin):
self.program.status = status
self.assertEqual(self.program.is_active, status == ProgramStatus.Active)
@responses.activate
def test_save_without_publish(self):
self.program.title = FuzzyText().fuzz()
self.program.save()
self.assert_responses_call_count(0)
def test_publication_disabled(self):
"""
Verify that the publisher is not initialized when publication is disabled.
"""
toggle_switch('publish_program_to_marketing_site', active=False)
@responses.activate
def test_delete_without_publish(self):
with mock.patch.object(ProgramMarketingSitePublisher, '__init__') as mock_init:
self.program.save()
self.program.delete()
self.assert_responses_call_count(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.type = ProgramType.objects.get(name='MicroMasters')
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.mock_add_alias()
self.mock_delete_alias()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_delete_alias_url', return_value='/foo'):
assert mock_init.call_count == 0
toggle_switch('publish_program_to_marketing_site')
self.program.partner.marketing_site_url_root = ''
self.program.partner.save()
with mock.patch.object(ProgramMarketingSitePublisher, '__init__') as mock_init:
self.program.save()
self.assert_responses_call_count(8)
self.program.delete()
@responses.activate
def test_xseries_program_save(self):
assert mock_init.call_count == 0
def test_publication_enabled(self):
"""
Make sure if the Program instance is of type XSeries, we do not publish to Marketing Site
Verify that the publisher is called when publication is enabled.
"""
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.type = ProgramType.objects.get(name='XSeries')
toggle_switch('publish_program_to_marketing_site', True)
self.program.title = FuzzyText().fuzz()
self.program.save()
self.assert_responses_call_count(0)
toggle_switch('publish_program_to_marketing_site')
@responses.activate
def test_save_and_no_marketing_site(self):
self.program.partner.marketing_site_url_root = None
toggle_switch('publish_program_to_marketing_site', True)
self.program.title = FuzzyText().fuzz()
with mock.patch.object(ProgramMarketingSitePublisher, 'publish_obj', return_value=None) as mock_publish_obj:
self.program.save()
self.assert_responses_call_count(0)
@responses.activate
def test_delete_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.type = ProgramType.objects.get(name='MicroMasters')
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_delete(204)
toggle_switch('publish_program_to_marketing_site', True)
self.program.delete()
self.assert_responses_call_count(5)
assert mock_publish_obj.called
@responses.activate
def test_delete_and_no_marketing_site(self):
self.program.partner.marketing_site_url_root = None
toggle_switch('publish_program_to_marketing_site', True)
with mock.patch.object(ProgramMarketingSitePublisher, 'delete_obj', return_value=None) as mock_delete_obj:
self.program.delete()
self.assert_responses_call_count(0)
assert mock_delete_obj.called
class PersonSocialNetworkTests(TestCase):
......
......@@ -3,8 +3,8 @@ import responses
from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.course_metadata.exceptions import PersonToMarketingException
from course_discovery.apps.course_metadata.people import MarketingSitePeople
from course_discovery.apps.course_metadata.publishers import MarketingSiteAPIClient
from course_discovery.apps.course_metadata.tests.mixins import MarketingSitePublisherTestMixin
from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClient
class MarketingSitePublisherTests(MarketingSitePublisherTestMixin):
......@@ -71,6 +71,6 @@ class MarketingSitePublisherTests(MarketingSitePublisherTestMixin):
@responses.activate
def test_delete_program(self):
self.mock_api_client(200)
self.mock_node_delete(204)
self.mock_node_delete(200)
people = MarketingSitePeople()
people.delete_person(self.partner, self.node_id)
import json
import mock
import pytest
import responses
from course_discovery.apps.course_metadata.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import ProgramType
from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.course_metadata.choices import CourseRunStatus, ProgramStatus
from course_discovery.apps.course_metadata.exceptions import (
AliasCreateError,
AliasDeleteError,
NodeCreateError,
NodeDeleteError,
NodeEditError,
NodeLookupError
)
from course_discovery.apps.course_metadata.publishers import (
MarketingSiteAPIClient, MarketingSitePublisher, ProgramPublisherException
BaseMarketingSitePublisher, CourseRunMarketingSitePublisher, ProgramMarketingSitePublisher
)
from course_discovery.apps.course_metadata.tests.factories import ProgramFactory
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ProgramFactory
from course_discovery.apps.course_metadata.tests.mixins import MarketingSitePublisherTestMixin
class MarketingSitePublisherTests(MarketingSitePublisherTestMixin):
class DummyObject:
dummy = 2
class BaseMarketingSitePublisherTests(MarketingSitePublisherTestMixin):
"""
Unit test cases for the MarketingSitePublisher
Tests covering shared publishing logic.
"""
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.type = ProgramType.objects.get(name='MicroMasters')
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
self.program.save() # pylint: disable=no-member
self.api_client = MarketingSiteAPIClient(
self.username,
self.password,
self.api_root
)
self.expected_node = {
'uuid': '945bb2c7-0a57-4a3f-972a-8c7f94aa0661',
'resource': 'node',
'uri': 'https://stage.edx.org/node/28426',
'id': '28426'
}
super().setUp()
self.partner = PartnerFactory()
self.publisher = BaseMarketingSitePublisher(self.partner)
self.publisher.unique_field = 'dummy'
self.publisher.node_lookup_field = 'field_dummy'
self.api_root = self.publisher.client.api_url
self.username = self.publisher.client.username
def test_get_node_data(self):
publisher = MarketingSitePublisher()
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access
self.obj = DummyObject()
def test_publish_obj(self):
"""
Verify that the base publisher doesn't implement this method.
"""
with pytest.raises(NotImplementedError):
self.publisher.publish_obj(self.obj)
@mock.patch.object(BaseMarketingSitePublisher, 'node_id', return_value='123')
@mock.patch.object(BaseMarketingSitePublisher, 'delete_node', return_value=None)
def test_delete_obj(self, mock_delete_node, mock_node_id):
"""
Verify that object deletion looks up the corresponding node ID and then
attempts to delete the node with that ID.
"""
self.publisher.delete_obj(self.obj)
mock_node_id.assert_called_with(self.obj)
mock_delete_node.assert_called_with('123')
@responses.activate
def test_serialize_obj(self):
"""
Verify that the base publisher serializes data required to publish any object.
"""
self.mock_api_client()
actual = self.publisher.serialize_obj(self.obj)
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 == ProgramStatus.Active else 0
'field_dummy': '2',
'author': {'id': self.user_id},
}
self.assertDictEqual(publish_data, expected)
assert actual == 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.assert_responses_call_count(4)
self.assertEqual(node_id, self.node_id)
def test_node_id(self):
"""
Verify that node ID lookup makes a request and pulls the ID out of the
response, and raises an exception for non-200 status codes.
"""
self.mock_api_client()
lookup_value = getattr(self.obj, self.publisher.unique_field)
self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value)
node_id = self.publisher.node_id(self.obj)
assert responses.calls[-1].request.url == '{base}?{field}={value}'.format(
base=self.publisher.node_api_base,
field=self.publisher.node_lookup_field,
value=lookup_value
)
assert node_id == self.node_id
responses.reset()
self.mock_api_client()
self.mock_node_retrieval(self.publisher.node_lookup_field, lookup_value, status=500)
with pytest.raises(NodeLookupError):
self.publisher.node_id(self.obj)
@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)
def test_create_node(self):
"""
Verify that node creation makes the correct request and returns the ID
contained in the response, and raises an exception for non-201 status codes.
"""
self.mock_api_client()
response_data = {'id': self.node_id}
self.mock_node_create(response_data, 201)
node_data = {'foo': 'bar'}
node_id = self.publisher.create_node(node_data)
assert responses.calls[-1].request.url == self.publisher.node_api_base
assert json.loads(responses.calls[-1].request.body) == node_data
assert node_id == self.node_id
responses.reset()
self.mock_api_client()
self.mock_node_create(response_data, 500)
with pytest.raises(NodeCreateError):
self.publisher.create_node(node_data)
@responses.activate
def test_edit_node(self):
self.mock_api_client(200)
"""
Verify that node editing makes the correct request and raises an exception
for non-200 status codes.
"""
self.mock_api_client()
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.node_id, publish_data) # pylint: disable=protected-access
self.assert_responses_call_count(4)
@responses.activate
def test_edit_node_failed(self):
self.mock_api_client(200)
node_data = {'foo': 'bar'}
self.publisher.edit_node(self.node_id, node_data)
assert responses.calls[-1].request.url == '{base}/{node_id}'.format(
base=self.publisher.node_api_base,
node_id=self.node_id
)
assert json.loads(responses.calls[-1].request.body) == node_data
responses.reset()
self.mock_api_client()
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.node_id, publish_data) # pylint: disable=protected-access
@responses.activate
def test_create_node(self):
self.mock_api_client(200)
self.mock_node_create(self.expected_node, 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, self.expected_node['id'])
with pytest.raises(NodeEditError):
self.publisher.edit_node(self.node_id, node_data)
@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
def test_delete_node(self):
"""
Verify that node deletion makes the correct request and raises an exception
for non-204 status codes.
"""
self.mock_api_client()
self.mock_node_delete(200)
@responses.activate
def test_publish_program_create(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid, exists=False)
self.mock_node_create(self.expected_node, 201)
publisher = MarketingSitePublisher()
self.mock_add_alias()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
publisher.publish_program(self.program)
self.assert_responses_call_count(7)
self.publisher.delete_node(self.node_id)
assert responses.calls[-1].request.url == '{base}/{node_id}'.format(
base=self.publisher.node_api_base,
node_id=self.node_id
)
responses.reset()
self.mock_api_client()
self.mock_node_delete(500)
with pytest.raises(NodeDeleteError):
self.publisher.delete_node(self.node_id)
class CourseRunMarketingSitePublisherTests(MarketingSitePublisherTestMixin):
"""
Tests covering course run-specific publishing logic.
"""
def setUp(self):
super().setUp()
self.partner = PartnerFactory()
self.publisher = CourseRunMarketingSitePublisher(self.partner)
self.api_root = self.publisher.client.api_url
self.username = self.publisher.client.username
self.obj = CourseRunFactory()
@mock.patch.object(CourseRunMarketingSitePublisher, 'node_id', return_value='node_id')
@mock.patch.object(CourseRunMarketingSitePublisher, 'serialize_obj', return_value='data')
@mock.patch.object(CourseRunMarketingSitePublisher, 'edit_node', return_value=None)
def test_publish_obj(self, mock_edit_node, *args): # pylint: disable=unused-argument
"""
Verify that the publisher attempts to publish when course run status changes.
"""
# No previous object. No editing should occur.
self.publisher.publish_obj(self.obj)
assert not mock_edit_node.called
# A previous object is provided, but the status hasn't changed.
# No editing should occur.
self.publisher.publish_obj(self.obj, previous_obj=self.obj)
assert not mock_edit_node.called
# A previous object is provided, and the status has changed.
# Editing should occur.
previous_obj = CourseRunFactory(status=CourseRunStatus.Unpublished)
self.publisher.publish_obj(self.obj, previous_obj=previous_obj)
mock_edit_node.assert_called_with('node_id', 'data')
@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()
self.mock_add_alias()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
publisher.publish_program(self.program)
self.assert_responses_call_count(7)
def test_serialize_obj(self):
"""
Verify that the publisher serializes data required to publish course runs.
"""
self.mock_api_client()
actual = self.publisher.serialize_obj(self.obj)
expected = {
'field_course_id': self.obj.key,
'author': {'id': self.user_id},
'status': 1,
}
assert actual == expected
self.obj.status = CourseRunStatus.Unpublished
actual = self.publisher.serialize_obj(self.obj)
expected['status'] = 0
assert actual == expected
class ProgramMarketingSitePublisherTests(MarketingSitePublisherTestMixin):
"""
Tests covering program-specific publishing logic.
"""
def setUp(self):
super().setUp()
self.partner = PartnerFactory()
self.publisher = ProgramMarketingSitePublisher(self.partner)
self.api_root = self.publisher.client.api_url
self.username = self.publisher.client.username
self.obj = ProgramFactory()
@mock.patch.object(ProgramMarketingSitePublisher, 'serialize_obj', return_value={'uuid': 'foo'})
@mock.patch.object(ProgramMarketingSitePublisher, 'node_id', return_value='node_id')
@mock.patch.object(ProgramMarketingSitePublisher, 'create_node', return_value='node_id')
@mock.patch.object(ProgramMarketingSitePublisher, 'edit_node', return_value=None)
@mock.patch.object(ProgramMarketingSitePublisher, 'update_node_alias', return_value=None)
def test_publish_obj(self, mock_update_node_alias, mock_edit_node, mock_create_node, *args): # pylint: disable=unused-argument
"""
Verify that the publisher only attempts to publish programs of certain types,
only attempts an edit when any one of a set of trigger fields is changed,
and always follows publication with an attempt to update the node alias.
"""
# Publication isn't supported for programs of this type.
self.publisher.publish_obj(self.obj)
mocked_methods = (mock_create_node, mock_edit_node, mock_update_node_alias)
for mocked_method in mocked_methods:
assert not mocked_method.called
for name in ('MicroMasters', 'Professional Certificate'):
for mocked_method in mocked_methods:
mocked_method.reset_mock()
# Publication is supported for programs of this type. No previous object
# is provided, so node creation should occur.
self.obj.type.name = name
self.publisher.publish_obj(self.obj)
assert mock_create_node.called
assert not mock_edit_node.called
assert mock_update_node_alias.called
for mocked_method in mocked_methods:
mocked_method.reset_mock()
# A previous object is provided, but none of the trigger fields have
# changed. Editing should not occur.
self.publisher.publish_obj(self.obj, previous_obj=self.obj)
for mocked_method in mocked_methods:
assert not mocked_method.called
# Trigger fields have changed. Editing should occur.
previous_obj = ProgramFactory()
self.publisher.publish_obj(self.obj, previous_obj=previous_obj)
assert not mock_create_node.called
assert mock_edit_node.called
assert mock_update_node_alias.called
@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)
self.mock_add_alias()
self.mock_delete_alias()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_delete_alias_url', return_value='/foo'):
publisher.publish_program(self.program)
self.assert_responses_call_count(8)
def test_serialize_obj(self):
"""
Verify that the publisher serializes data required to publish programs.
"""
self.mock_api_client()
actual = self.publisher.serialize_obj(self.obj)
expected = {
'field_uuid': str(self.obj.uuid),
'author': {'id': self.user_id},
'status': 1,
'title': self.obj.title,
'type': str(self.obj.type).lower().replace(' ', '_'),
'uuid': str(self.obj.uuid),
}
assert actual == expected
self.obj.status = ProgramStatus.Unpublished
actual = self.publisher.serialize_obj(self.obj)
expected['status'] = 0
assert actual == expected
@responses.activate
def test_get_alias_form(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_edit(200)
publisher = MarketingSitePublisher()
def test_update_node_alias(self):
"""
Verify that the publisher attempts to create a new alias when necessary
and deletes an old alias, if one existed, and that appropriate exceptions
are raised for non-200 status codes.
"""
# No previous object is provided. Alias creation should occur, but alias
# deletion should not.
self.mock_api_client()
self.mock_get_alias_form()
self.mock_add_alias()
self.publisher.update_node_alias(self.obj, self.node_id, None)
assert responses.calls[-1].request.url == '{}/add'.format(self.publisher.alias_api_base)
responses.reset()
# Same scenario, but this time a non-200 status code is returned during
# alias creation. An exception should be raised.
self.mock_api_client()
self.mock_get_alias_form()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
publisher.publish_program(self.program)
self.assert_responses_call_count(8)
self.mock_add_alias(status=500)
@responses.activate
def test_get_delete_form(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)
self.mock_add_alias()
self.mock_get_delete_form(program_before.marketing_slug)
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
publisher.publish_program(self.program)
self.assert_responses_call_count(9)
with pytest.raises(AliasCreateError):
self.publisher.update_node_alias(self.obj, self.node_id, None)
@responses.activate
def test_get_alias_form_failed(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_edit(200)
publisher = MarketingSitePublisher()
self.mock_add_alias()
self.mock_get_alias_form(500)
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with self.assertRaises(ProgramPublisherException):
publisher.publish_program(self.program)
responses.reset()
@responses.activate
def test_get_delete_form_failed(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)
# A previous object is provided, but the marketing slug hasn't changed.
# Neither alias creation nor alias deletion should occur.
self.mock_api_client()
self.publisher.update_node_alias(self.obj, self.node_id, self.obj)
responses.reset()
# A previous object is provided, and the marketing slug has changed.
# Both alias creation and alias deletion should occur.
previous_obj = ProgramFactory()
self.mock_api_client()
self.mock_get_alias_form()
self.mock_add_alias()
self.mock_get_delete_form(program_before.marketing_slug, 500)
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
with self.assertRaises(ProgramPublisherException):
publisher.publish_program(self.program)
self.mock_get_delete_form(previous_obj.marketing_slug)
self.mock_delete_alias()
@responses.activate
def test_add_alias_failed(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_edit(200)
publisher = MarketingSitePublisher()
self.mock_add_alias(500)
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
with self.assertRaises(ProgramPublisherException):
publisher.publish_program(self.program)
self.publisher.update_node_alias(self.obj, self.node_id, previous_obj)
@responses.activate
def test_publish_unmodified_program(self):
self.mock_api_client(200)
publisher = MarketingSitePublisher(self.program)
publisher.publish_program(self.program)
self.assert_responses_call_count(0)
assert any('/add' in call.request.url for call in responses.calls)
assert any('/list/{}'.format(previous_obj.marketing_slug) in call.request.url for call in responses.calls)
@responses.activate
def test_publish_xseries_program(self):
self.program.type = ProgramType.objects.get(name='XSeries')
publisher = MarketingSitePublisher()
publisher.publish_program(self.program)
self.assert_responses_call_count(0)
responses.reset()
@responses.activate
def test_publish_program_no_credential(self):
self.program.partner.marketing_site_api_password = None
self.program.partner.marketing_site_api_username = None
publisher = MarketingSitePublisher()
with self.assertRaises(ProgramPublisherException):
publisher.publish_program(self.program)
self.assert_responses_call_count(0)
# Same scenario, but this time a non-200 status code is returned during
# alias deletion. An exception should be raised.
self.mock_api_client()
self.mock_get_alias_form()
self.mock_add_alias()
self.mock_get_delete_form(previous_obj.marketing_slug)
self.mock_delete_alias(status=500)
@responses.activate
def test_publish_delete_program(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid)
self.mock_node_delete(204)
publisher = MarketingSitePublisher()
publisher.delete_program(self.program)
self.assert_responses_call_count(5)
with pytest.raises(AliasDeleteError):
self.publisher.update_node_alias(self.obj, self.node_id, previous_obj)
@responses.activate
def test_publish_delete_non_existent_program(self):
self.mock_api_client(200)
self.mock_node_retrieval(self.program.uuid, exists=False)
publisher = MarketingSitePublisher()
publisher.delete_program(self.program)
self.assert_responses_call_count(4)
def test_alias(self):
"""
Verify that aliases are constructed correctly.
"""
actual = self.publisher.alias(self.obj)
expected = '{type}/{slug}'.format(type=self.obj.type.slug, slug=self.obj.marketing_slug)
@responses.activate
def test_publish_delete_xseries(self):
self.program = ProgramFactory(type=ProgramType.objects.get(name='XSeries'))
publisher = MarketingSitePublisher()
publisher.delete_program(self.program)
self.assert_responses_call_count(0)
assert actual == expected
# pylint: disable=no-member
from unittest.mock import patch
from django.test import TestCase
from course_discovery.apps.course_metadata.models import ProgramType
from course_discovery.apps.course_metadata.tests import factories, toggle_switch
MARKETING_SITE_PUBLISHERS_MODULE = 'course_discovery.apps.course_metadata.publishers.MarketingSitePublisher'
@patch(MARKETING_SITE_PUBLISHERS_MODULE + '.delete_program')
class SignalsTest(TestCase):
def setUp(self):
super(SignalsTest, self).setUp()
self.program = factories.ProgramFactory(type=ProgramType.objects.get(name='MicroMasters'))
def test_delete_program_signal_no_publish(self, delete_program_mock):
toggle_switch('publish_program_to_marketing_site', False)
self.program.delete()
self.assertFalse(delete_program_mock.called)
def test_delete_program_signal_with_publish(self, delete_program_mock):
toggle_switch('publish_program_to_marketing_site', True)
self.program.delete()
delete_program_mock.assert_called_once_with(self.program)
......@@ -28,14 +28,14 @@ class CourseRunSelectionAdmin(UpdateView):
context = super(CourseRunSelectionAdmin, self).get_context_data(**kwargs)
context.update({
'program_id': self.object.id,
'title': _('Change program excluded course runs')
'title': _('Update excluded course runs')
})
return context
raise Http404
def form_valid(self, form):
self.object = form.save()
message = 'The program course runs was changed successfully.'
message = _('The program was changed successfully.')
messages.add_message(self.request, messages.SUCCESS, message)
return HttpResponseRedirect(self.get_success_url())
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-20 16:51+0500\n"
"POT-Creation-Date: 2017-04-20 11:56-0400\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"
......@@ -201,6 +201,13 @@ msgid "Partners"
msgstr ""
#: apps/course_metadata/admin.py
#, python-brace-format
msgid ""
"An error occurred while publishing the {model} to the marketing site. Please"
" try again. If the error persists, please contact the Engineering Team."
msgstr ""
#: apps/course_metadata/admin.py
msgid "eligible for one-click purchase"
msgstr ""
......@@ -217,12 +224,6 @@ msgstr ""
msgid "Included course runs"
msgstr ""
#: apps/course_metadata/admin.py
msgid ""
"An error occurred while publishing the program to the marketing site. Please"
" try again. If the error persists, please contact the Engineering Team."
msgstr ""
#: apps/course_metadata/choices.py apps/publisher/choices.py
msgid "Published"
msgstr ""
......@@ -440,7 +441,11 @@ msgid "Allow courses in this program to be purchased in a single transaction"
msgstr ""
#: apps/course_metadata/views.py
msgid "Change program excluded course runs"
msgid "Update excluded course runs"
msgstr ""
#: apps/course_metadata/views.py
msgid "The program was changed successfully."
msgstr ""
#: apps/publisher/api/serializers.py
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-20 16:51+0500\n"
"POT-Creation-Date: 2017-04-20 11:56-0400\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-04-20 16:51+0500\n"
"POT-Creation-Date: 2017-04-20 11:56-0400\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"
......@@ -242,6 +242,21 @@ msgid "Partners"
msgstr "Pärtnérs Ⱡ'σяєм ιρѕυм ∂#"
#: apps/course_metadata/admin.py
#, python-brace-format
msgid ""
"An error occurred while publishing the {model} to the marketing site. Please"
" try again. If the error persists, please contact the Engineering Team."
msgstr ""
"Àn érrör öççürréd whïlé püßlïshïng thé {model} tö thé märkétïng sïté. Pléäsé"
" trý ägäïn. Ìf thé érrör pérsïsts, pléäsé çöntäçt thé Éngïnéérïng Téäm. "
"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ "
"тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм,"
" qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ "
"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє "
"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт ¢υρι∂αтαт "
"ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм ι∂ єѕ#"
#: apps/course_metadata/admin.py
msgid "eligible for one-click purchase"
msgstr "élïgïßlé för öné-çlïçk pürçhäsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
......@@ -258,20 +273,6 @@ msgstr "Nö Ⱡ'σя#"
msgid "Included course runs"
msgstr "Ìnçlüdéd çöürsé rüns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #"
#: apps/course_metadata/admin.py
msgid ""
"An error occurred while publishing the program to the marketing site. Please"
" try again. If the error persists, please contact the Engineering Team."
msgstr ""
"Àn érrör öççürréd whïlé püßlïshïng thé prögräm tö thé märkétïng sïté. Pléäsé"
" trý ägäïn. Ìf thé érrör pérsïsts, pléäsé çöntäçt thé Éngïnéérïng Téäm. "
"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ "
"тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм,"
" qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ "
"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє "
"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт ¢υρι∂αтαт "
"ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм #"
#: apps/course_metadata/choices.py apps/publisher/choices.py
msgid "Published"
msgstr "Püßlïshéd Ⱡ'σяєм ιρѕυм ∂σł#"
......@@ -551,9 +552,14 @@ msgstr ""
" ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
#: apps/course_metadata/views.py
msgid "Change program excluded course runs"
msgid "Update excluded course runs"
msgstr "Ûpdäté éxçlüdéd çöürsé rüns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
#: apps/course_metadata/views.py
msgid "The program was changed successfully."
msgstr ""
"Çhängé prögräm éxçlüdéd çöürsé rüns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєт#"
"Thé prögräm wäs çhängéd süççéssfüllý. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
"¢σηѕє¢тєтυ#"
#: apps/publisher/api/serializers.py
#, python-brace-format
......
......@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-20 16:51+0500\n"
"POT-Creation-Date: 2017-04-20 11:56-0400\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