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 ...@@ -4,10 +4,17 @@ from django.http import HttpResponseRedirect
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ 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.forms import CourseAdminForm, ProgramAdminForm
from course_discovery.apps.course_metadata.models import * # pylint: disable=wildcard-import 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): class ProgramEligibilityFilter(admin.SimpleListFilter):
...@@ -101,6 +108,24 @@ class CourseRunAdmin(admin.ModelAdmin): ...@@ -101,6 +108,24 @@ class CourseRunAdmin(admin.ModelAdmin):
ordering = ('key',) ordering = ('key',)
readonly_fields = ('uuid',) readonly_fields = ('uuid',)
search_fields = ('uuid', 'key', 'title_override', 'course__title', 'slug',) 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) @admin.register(Program)
...@@ -158,15 +183,14 @@ class ProgramAdmin(admin.ModelAdmin): ...@@ -158,15 +183,14 @@ class ProgramAdmin(admin.ModelAdmin):
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
try: try:
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
self.save_error = False except (MarketingSitePublisherException, MarketingSiteAPIClientException):
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)
self.save_error = True 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: class Media:
js = ('bower_components/jquery-ui/ui/minified/jquery-ui.min.js', js = ('bower_components/jquery-ui/ui/minified/jquery-ui.min.js',
'js/sortable_select.js') 'js/sortable_select.js')
......
class MarketingSiteAPIClientException(Exception): class MarketingSiteAPIClientException(Exception):
""" The exception thrown from MarketingSiteAPIClient """
pass pass
class ProgramPublisherException(Exception): class MarketingSitePublisherException(Exception):
""" The exception thrown during the program publishing process to marketing site """ pass
def __init__(self, message):
super(ProgramPublisherException, self).__init__(message) class AliasCreateError(MarketingSitePublisherException):
suffix = 'The program data has not been saved. Please check your marketing site configuration' pass
self.message = '{exception_msg} {suffix}'.format(exception_msg=message, suffix=suffix)
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): class PersonToMarketingException(Exception):
......
...@@ -94,8 +94,8 @@ class CourseRunSelectionForm(forms.ModelForm): ...@@ -94,8 +94,8 @@ class CourseRunSelectionForm(forms.ModelForm):
) )
query_set = [course.pk for course in instance.courses.all()] query_set = [course.pk for course in instance.courses.all()]
self.fields["excluded_course_runs"].widget = forms.widgets.CheckboxSelectMultiple() self.fields['excluded_course_runs'].widget = forms.widgets.CheckboxSelectMultiple()
self.fields["excluded_course_runs"].help_text = "" self.fields['excluded_course_runs'].help_text = ''
self.fields['excluded_course_runs'].queryset = CourseRun.objects.filter( self.fields['excluded_course_runs'].queryset = CourseRun.objects.filter(
course__id__in=query_set 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 ...@@ -23,7 +23,10 @@ from taggit_autosuggest.managers import TaggableManager
from course_discovery.apps.core.models import Currency, Partner 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.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.query import CourseQuerySet, CourseRunQuerySet, ProgramQuerySet
from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath, clean_query, custom_render_variations from course_discovery.apps.course_metadata.utils import UploadToFieldNamePath, clean_query, custom_render_variations
from course_discovery.apps.ietf_language_tags.models import LanguageTag from course_discovery.apps.ietf_language_tags.models import LanguageTag
...@@ -337,7 +340,6 @@ class Course(TimeStampedModel): ...@@ -337,7 +340,6 @@ class Course(TimeStampedModel):
class CourseRun(TimeStampedModel): class CourseRun(TimeStampedModel):
""" CourseRun model. """ """ CourseRun model. """
uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID')) uuid = models.UUIDField(default=uuid4, editable=False, verbose_name=_('UUID'))
course = models.ForeignKey(Course, related_name='course_runs') course = models.ForeignKey(Course, related_name='course_runs')
key = models.CharField(max_length=255, unique=True) key = models.CharField(max_length=255, unique=True)
...@@ -595,6 +597,22 @@ class CourseRun(TimeStampedModel): ...@@ -595,6 +597,22 @@ class CourseRun(TimeStampedModel):
def __str__(self): def __str__(self):
return '{key}: {title}'.format(key=self.key, title=self.title) 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): class SeatType(TimeStampedModel):
name = models.CharField(max_length=64, unique=True) name = models.CharField(max_length=64, unique=True)
...@@ -973,19 +991,18 @@ class Program(TimeStampedModel): ...@@ -973,19 +991,18 @@ class Program(TimeStampedModel):
return self.status == ProgramStatus.Active return self.status == ProgramStatus.Active
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if waffle.switch_is_active('publish_program_to_marketing_site') and self.partner.has_marketing_site: is_publishable = (
# Before save, get from database the existing data if exists self.partner.has_marketing_site and
existing_program = None waffle.switch_is_active('publish_program_to_marketing_site')
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 if is_publishable:
publisher = MarketingSitePublisher(existing_program) publisher = ProgramMarketingSitePublisher(self.partner)
previous_obj = Program.objects.get(id=self.id) if self.id else None
with transaction.atomic(): with transaction.atomic():
super(Program, self).save(*args, **kwargs) super(Program, self).save(*args, **kwargs)
# Once save complete, we need to update the marketing site publisher.publish_obj(self, previous_obj=previous_obj)
# So the marketing page for this program is automatically updated
publisher.publish_program(self)
else: else:
super(Program, self).save(*args, **kwargs) super(Program, self).save(*args, **kwargs)
......
import json import json
from urllib.parse import urljoin
from bs4 import BeautifulSoup 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 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): def __init__(self, partner):
if program_before: self.partner = partner
self.program_before = program_before
def _get_api_client(self, program): self.client = MarketingSiteAPIClient(
if not program.partner.has_marketing_site: self.partner.marketing_site_api_username,
return 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): self.node_api_base = urljoin(self.client.api_url, '/node.json')
msg = 'Marketing Site API credentials are not properly configured for Partner [{partner}]!'.format(
partner=program.partner.short_code)
raise ProgramPublisherException(msg)
if program.type.name not in ['MicroMasters', 'Professional Certificate']: def publish_obj(self, obj, previous_obj=None):
# We do not publish programs that are not MicroMasters or Professional Certificate to the Marketing Site """
return Update or create a Drupal node corresponding to the given model instance.
fields_that_trigger_publish = ['title', 'status', 'type', 'marketing_slug'] Arguments:
if self.program_before and \ obj (django.db.models.Model): Model instance to be published.
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
return MarketingSiteAPIClient( Keyword Arguments:
program.partner.marketing_site_api_username, previous_obj (CourseRun): Model instance representing the previous
program.partner.marketing_site_api_password, state of the model being changed. Inspected to determine if publication
program.partner.marketing_site_url_root 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 { return {
'type': str(program.type).lower().replace(' ', '_'), self.node_lookup_field: str(getattr(obj, self.unique_field)),
'title': program.title, 'author': {'id': self.client.user_id},
'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): def node_id(self, obj):
node_url = '{root}/node.json?field_uuid={uuid}'.format(root=api_client.api_url, uuid=uuid) """
response = api_client.api_session.get(node_url) 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: if response.status_code == 200:
list_item = response.json().get('list') return response.json()['list'][0]['nid']
if list_item: else:
return list_item[0]['nid'] raise NodeLookupError
def _edit_node(self, api_client, node_id, node_data): def create_node(self, node_data):
# Drupal does not allow us to update the UUID field on node update """
node_data.pop('uuid', None) Create a Drupal node.
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)) Arguments:
if response.status_code != 200: node_data (dict): Data to POST to Drupal for node creation.
raise ProgramPublisherException("Marketing site page edit failed!")
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: if response.status_code == 201:
response_json = response.json() return response.json()['id']
return response_json['id']
else: 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): Arguments:
node_url = '{root}/node.json/{node_id}'.format(root=api_client.api_url, node_id=node_id) node_id (str): ID of the node to edit.
api_client.api_session.delete(node_url) 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: if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias form retrieval failed!') raise NodeEditError
form = BeautifulSoup(response.text, 'html.parser')
for field in ('form_build_id', 'form_token'): def delete_node(self, node_id):
form_attributes[field] = form.find('input', {'name': field}).get('value') """
return form_attributes Delete a Drupal node.
def _get_delete_alias_url(self, api_client, url): Arguments:
response = api_client.api_session.get(url) 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: if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias form retrieval failed!') raise NodeDeleteError
form = BeautifulSoup(response.text, 'html.parser')
delete_element = form.select('.delete.last a')
return delete_element[0].get('href') if delete_element else None
def _get_headers(self):
headers = { class CourseRunMarketingSitePublisher(BaseMarketingSitePublisher):
'content-type': 'application/x-www-form-urlencoded' """
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): class ProgramMarketingSitePublisher(BaseMarketingSitePublisher):
base_aliases_url = '{root}/admin/config/search/path'.format(root=api_client.api_url) """
add_aliases_url = '{url}/add'.format(url=base_aliases_url) Utility for publishing program data to a Drupal marketing site.
node_url = 'node/{node_id}'.format(node_id=node_id) """
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 = { data = {
'source': node_url, **self.alias_form_inputs,
'alias': alias, 'alias': new_alias,
'form_id': 'path_admin_form', '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) response = self.client.api_session.post(alias_add_url, headers=headers, data=data)
headers = self._get_headers()
response = api_client.api_session.post(add_aliases_url, headers=headers, data=data)
if response.status_code != 200: if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias creation failed!') raise AliasCreateError
# Delete old alias after saving new one # Delete old alias after saving the new one.
if before_slug: if previous_obj:
list_aliases_url = '{url}/list/{slug}'.format(url=base_aliases_url, slug=before_slug) alias_list_url = '{base}/list/{slug}'.format(
delete_alias_url = self._get_delete_alias_url(api_client, list_aliases_url) base=self.alias_api_base,
if delete_alias_url: slug=previous_obj.marketing_slug
delete_alias_url = '{root}{url}'.format(root=api_client.api_url, url=delete_alias_url) )
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 = { data = {
"confirm": 1, **self.alias_form_inputs,
"form_id": "path_admin_delete_confirm", 'confirm': 1,
"op": "Confirm" '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 = self.client.api_session.post(alias_delete_url, headers=headers, data=data)
response = api_client.api_session.post(delete_alias_url, headers=headers, data=data)
if response.status_code != 200: if response.status_code != 200:
raise ProgramPublisherException('Marketing site alias deletion failed!') raise AliasDeleteError
def publish_program(self, program): def alias(self, obj):
api_client = self._get_api_client(program) return '{type}/{slug}'.format(type=obj.type.slug, slug=obj.marketing_slug)
if api_client:
node_data = self._get_node_data(program, api_client.user_id) @cached_property
node_id = self._get_node_id(api_client, program.uuid) def alias_form_inputs(self):
if node_id: """
# We would like to edit the existing node Scrape input values from the form used to modify Drupal aliases.
self._edit_node(api_client, node_id, node_data)
else: Raises:
# We should create a new node FormRetrievalError: If there's a problem getting the form from Drupal.
node_id = self._create_node(api_client, node_data) """
before_alias = self._make_alias(self.program_before) if self.program_before else None response = self.client.api_session.get(self.alias_add_url)
new_alias = self._make_alias(program)
before_slug = self.program_before.marketing_slug if self.program_before else None if response.status_code != 200:
if not self.program_before or (before_alias != new_alias): raise FormRetrievalError
self._add_alias(api_client, node_id, new_alias, before_slug)
html = BeautifulSoup(response.text, 'html.parser')
def delete_program(self, program):
api_client = self._get_api_client(program) return {
if api_client: field: html.find('input', {'name': field}).get('value')
node_id = self._get_node_id(api_client, program.uuid) for field in ('form_build_id', 'form_token')
if node_id: }
self._delete_node(api_client, node_id)
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 ...@@ -3,12 +3,16 @@ from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from course_discovery.apps.course_metadata.models import Program 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) @receiver(pre_delete, sender=Program)
def delete_program(sender, instance, **kwargs): # pylint: disable=unused-argument def delete_program(sender, instance, **kwargs): # pylint: disable=unused-argument
if waffle.switch_is_active('publish_program_to_marketing_site') and \ is_publishable = (
instance.partner.has_marketing_site: instance.partner.has_marketing_site and
publisher = MarketingSitePublisher() waffle.switch_is_active('publish_program_to_marketing_site')
publisher.delete_program(instance) )
if is_publishable:
publisher = ProgramMarketingSitePublisher(instance.partner)
publisher.delete_obj(instance)
import json import json
import random
import urllib import urllib
import responses import responses
...@@ -77,49 +78,46 @@ class MarketingSiteAPIClientTestMixin(TestCase): ...@@ -77,49 +78,46 @@ class MarketingSiteAPIClientTestMixin(TestCase):
class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin): 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): def setUp(self):
super(MarketingSitePublisherTestMixin, self).setUp() 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_login_response(status)
self.mock_csrf_token_response(status) self.mock_csrf_token_response(status)
self.mock_user_id_response(status) self.mock_user_id_response(status)
def mock_node_retrieval(self, program_uuid, exists=True): def mock_node_retrieval(self, node_lookup_field, node_lookup_value, exists=True, status=200):
data = {} url = '{root}/node.json?{node_lookup_field}={node_lookup_value}'.format(
status = 200 root=self.api_root,
node_lookup_field=node_lookup_field,
node_lookup_value=node_lookup_value,
)
data = { data = {
'list': [] 'list': [{'nid': self.node_id}] if exists else []
} }
if exists:
data['list'] = [{
'nid': self.node_id
}]
responses.add( responses.add(
responses.GET, responses.GET,
'{root}/node.json?field_uuid={uuid}'.format(root=self.api_root, uuid=str(program_uuid)), url,
body=json.dumps(data), body=json.dumps(data),
content_type='application/json', content_type='application/json',
status=status, status=status,
match_querystring=True 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) 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 = { data = {
'source': node_url, 'source': node_url,
'alias': alias, 'alias': alias,
'form_id': 'path_admin_form', 'form_id': 'path_admin_form',
'op': 'Save' 'op': 'Save'
} }
responses.add( responses.add(
responses.POST, responses.POST,
'{root}/admin/config/search/path/add'.format(root=self.api_root), '{root}/admin/config/search/path/add'.format(root=self.api_root),
...@@ -129,13 +127,14 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin): ...@@ -129,13 +127,14 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
def mock_delete_alias(self, status=200): def mock_delete_alias(self, status=200):
data = { data = {
"confirm": 1, 'confirm': 1,
"form_id": "path_admin_delete_confirm", 'form_id': 'path_admin_delete_confirm',
"op": "Confirm" 'op': 'Confirm'
} }
responses.add( responses.add(
responses.POST, 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), body=urllib.parse.urlencode(data),
status=status status=status
) )
...@@ -154,12 +153,15 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin): ...@@ -154,12 +153,15 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
responses.GET, responses.GET,
'{root}/admin/config/search/path/list/{alias}'.format(root=self.api_root, alias=alias), '{root}/admin/config/search/path/list/{alias}'.format(root=self.api_root, alias=alias),
status=status, 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.add(
responses.POST, 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 status=status
) )
...@@ -172,15 +174,6 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin): ...@@ -172,15 +174,6 @@ class MarketingSitePublisherTestMixin(MarketingSiteAPIClientTestMixin):
status=status 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): def mock_node_delete(self, status):
responses.add( responses.add(
responses.DELETE, responses.DELETE,
......
...@@ -4,13 +4,11 @@ from decimal import Decimal ...@@ -4,13 +4,11 @@ from decimal import Decimal
import ddt import ddt
import mock import mock
import responses
from dateutil.parser import parse from dateutil.parser import parse
from django.conf import settings from django.conf import settings
from django.db import IntegrityError from django.db import IntegrityError
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.test import TestCase from django.test import TestCase
from factory.fuzzy import FuzzyText
from freezegun import freeze_time from freezegun import freeze_time
from course_discovery.apps.core.models import Currency from course_discovery.apps.core.models import Currency
...@@ -20,12 +18,13 @@ from course_discovery.apps.core.utils import SearchQuerySetWrapper ...@@ -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.choices import ProgramStatus
from course_discovery.apps.course_metadata.models import ( from course_discovery.apps.course_metadata.models import (
FAQ, AbstractMediaModel, AbstractNamedModel, AbstractValueModel, CorporateEndorsement, Course, CourseRun, 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 import factories, toggle_switch
from course_discovery.apps.course_metadata.tests.factories import CourseRunFactory, ImageFactory 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 from course_discovery.apps.ietf_language_tags.models import LanguageTag
...@@ -257,7 +256,7 @@ class CourseRunTests(TestCase): ...@@ -257,7 +256,7 @@ class CourseRunTests(TestCase):
@ddt.unpack @ddt.unpack
def test_get_paid_seat_enrollment_end(self, seat_config, course_end, course_enrollment_end, expected_result): 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. 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 end = parse(course_end) if course_end else None
...@@ -270,6 +269,43 @@ class CourseRunTests(TestCase): ...@@ -270,6 +269,43 @@ class CourseRunTests(TestCase):
expected_result = parse(expected_result) if expected_result else None expected_result = parse(expected_result) if expected_result else None
self.assertEqual(course_run.get_paid_seat_enrollment_end(), expected_result) 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): class OrganizationTests(TestCase):
""" Tests for the `Organization` model. """ """ Tests for the `Organization` model. """
...@@ -390,7 +426,7 @@ class AbstractValueModelTests(TestCase): ...@@ -390,7 +426,7 @@ class AbstractValueModelTests(TestCase):
@ddt.ddt @ddt.ddt
class ProgramTests(MarketingSitePublisherTestMixin): class ProgramTests(TestCase):
"""Tests of the Program model.""" """Tests of the Program model."""
def setUp(self): def setUp(self):
...@@ -737,77 +773,41 @@ class ProgramTests(MarketingSitePublisherTestMixin): ...@@ -737,77 +773,41 @@ class ProgramTests(MarketingSitePublisherTestMixin):
self.program.status = status self.program.status = status
self.assertEqual(self.program.is_active, status == ProgramStatus.Active) self.assertEqual(self.program.is_active, status == ProgramStatus.Active)
@responses.activate def test_publication_disabled(self):
def test_save_without_publish(self): """
self.program.title = FuzzyText().fuzz() Verify that the publisher is not initialized when publication is disabled.
self.program.save() """
self.assert_responses_call_count(0) toggle_switch('publish_program_to_marketing_site', active=False)
@responses.activate with mock.patch.object(ProgramMarketingSitePublisher, '__init__') as mock_init:
def test_delete_without_publish(self): self.program.save()
self.program.delete() self.program.delete()
self.assert_responses_call_count(0)
assert mock_init.call_count == 0
@responses.activate
def test_save_and_publish_success(self): toggle_switch('publish_program_to_marketing_site')
self.program.partner.marketing_site_url_root = self.api_root self.program.partner.marketing_site_url_root = ''
self.program.partner.marketing_site_api_username = self.username self.program.partner.save()
self.program.partner.marketing_site_api_password = self.password
self.program.type = ProgramType.objects.get(name='MicroMasters') with mock.patch.object(ProgramMarketingSitePublisher, '__init__') as mock_init:
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'):
self.program.save() self.program.save()
self.assert_responses_call_count(8) self.program.delete()
@responses.activate assert mock_init.call_count == 0
def test_xseries_program_save(self):
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 toggle_switch('publish_program_to_marketing_site')
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)
@responses.activate with mock.patch.object(ProgramMarketingSitePublisher, 'publish_obj', return_value=None) as mock_publish_obj:
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()
self.program.save() self.program.save()
self.assert_responses_call_count(0) assert mock_publish_obj.called
@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)
@responses.activate with mock.patch.object(ProgramMarketingSitePublisher, 'delete_obj', return_value=None) as mock_delete_obj:
def test_delete_and_no_marketing_site(self):
self.program.partner.marketing_site_url_root = None
toggle_switch('publish_program_to_marketing_site', True)
self.program.delete() self.program.delete()
self.assert_responses_call_count(0) assert mock_delete_obj.called
class PersonSocialNetworkTests(TestCase): class PersonSocialNetworkTests(TestCase):
......
...@@ -3,8 +3,8 @@ import responses ...@@ -3,8 +3,8 @@ import responses
from course_discovery.apps.core.tests.factories import PartnerFactory from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.course_metadata.exceptions import PersonToMarketingException from course_discovery.apps.course_metadata.exceptions import PersonToMarketingException
from course_discovery.apps.course_metadata.people import MarketingSitePeople 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.tests.mixins import MarketingSitePublisherTestMixin
from course_discovery.apps.course_metadata.utils import MarketingSiteAPIClient
class MarketingSitePublisherTests(MarketingSitePublisherTestMixin): class MarketingSitePublisherTests(MarketingSitePublisherTestMixin):
...@@ -71,6 +71,6 @@ class MarketingSitePublisherTests(MarketingSitePublisherTestMixin): ...@@ -71,6 +71,6 @@ class MarketingSitePublisherTests(MarketingSitePublisherTestMixin):
@responses.activate @responses.activate
def test_delete_program(self): def test_delete_program(self):
self.mock_api_client(200) self.mock_api_client(200)
self.mock_node_delete(204) self.mock_node_delete(200)
people = MarketingSitePeople() people = MarketingSitePeople()
people.delete_person(self.partner, self.node_id) people.delete_person(self.partner, self.node_id)
import json
import mock import mock
import pytest
import responses import responses
from course_discovery.apps.course_metadata.choices import ProgramStatus from course_discovery.apps.core.tests.factories import PartnerFactory
from course_discovery.apps.course_metadata.models import ProgramType 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 ( 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 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): def setUp(self):
super(MarketingSitePublisherTests, self).setUp() super().setUp()
self.program = ProgramFactory()
self.program.partner.marketing_site_url_root = self.api_root self.partner = PartnerFactory()
self.program.partner.marketing_site_api_username = self.username self.publisher = BaseMarketingSitePublisher(self.partner)
self.program.partner.marketing_site_api_password = self.password self.publisher.unique_field = 'dummy'
self.program.type = ProgramType.objects.get(name='MicroMasters') self.publisher.node_lookup_field = 'field_dummy'
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}):
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}): self.api_root = self.publisher.client.api_url
self.program.save() # pylint: disable=no-member self.username = self.publisher.client.username
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'
}
def test_get_node_data(self): self.obj = DummyObject()
publisher = MarketingSitePublisher()
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access 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 = { expected = {
'type': str(self.program.type).lower(), 'field_dummy': '2',
'title': self.program.title, 'author': {'id': self.user_id},
'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
} }
self.assertDictEqual(publish_data, expected)
assert actual == expected
@responses.activate @responses.activate
def test_get_node_id(self): def test_node_id(self):
self.mock_api_client(200) """
self.mock_node_retrieval(self.program.uuid) Verify that node ID lookup makes a request and pulls the ID out of the
publisher = MarketingSitePublisher() response, and raises an exception for non-200 status codes.
node_id = publisher._get_node_id(self.api_client, self.program.uuid) # pylint: disable=protected-access """
self.assert_responses_call_count(4) self.mock_api_client()
self.assertEqual(node_id, self.node_id)
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 @responses.activate
def test_get_non_existent_node_id(self): def test_create_node(self):
self.mock_api_client(200) """
self.mock_node_retrieval(self.program.uuid, exists=False) Verify that node creation makes the correct request and returns the ID
publisher = MarketingSitePublisher() contained in the response, and raises an exception for non-201 status codes.
node_id = publisher._get_node_id(self.api_client, self.program.uuid) # pylint: disable=protected-access """
self.assertIsNone(node_id) 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 @responses.activate
def test_edit_node(self): 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) 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 node_data = {'foo': 'bar'}
def test_edit_node_failed(self): self.publisher.edit_node(self.node_id, node_data)
self.mock_api_client(200)
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) 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 with pytest.raises(NodeEditError):
def test_create_node(self): self.publisher.edit_node(self.node_id, node_data)
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'])
@responses.activate @responses.activate
def test_create_node_failed(self): def test_delete_node(self):
self.mock_api_client(200) """
self.mock_node_create({}, 500) Verify that node deletion makes the correct request and raises an exception
publisher = MarketingSitePublisher() for non-204 status codes.
publish_data = publisher._get_node_data(self.program, self.user_id) # pylint: disable=protected-access """
with self.assertRaises(ProgramPublisherException): self.mock_api_client()
publisher._create_node(self.api_client, publish_data) # pylint: disable=protected-access self.mock_node_delete(200)
@responses.activate self.publisher.delete_node(self.node_id)
def test_publish_program_create(self):
self.mock_api_client(200) assert responses.calls[-1].request.url == '{base}/{node_id}'.format(
self.mock_node_retrieval(self.program.uuid, exists=False) base=self.publisher.node_api_base,
self.mock_node_create(self.expected_node, 201) node_id=self.node_id
publisher = MarketingSitePublisher() )
self.mock_add_alias()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}): responses.reset()
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
publisher.publish_program(self.program) self.mock_api_client()
self.assert_responses_call_count(7) 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 @responses.activate
def test_publish_program_edit(self): def test_serialize_obj(self):
self.mock_api_client(200) """
self.mock_node_retrieval(self.program.uuid) Verify that the publisher serializes data required to publish course runs.
self.mock_node_edit(200) """
publisher = MarketingSitePublisher() self.mock_api_client()
self.mock_add_alias()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}): actual = self.publisher.serialize_obj(self.obj)
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}): expected = {
publisher.publish_program(self.program) 'field_course_id': self.obj.key,
self.assert_responses_call_count(7) '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 @responses.activate
def test_publish_modified_program(self): def test_serialize_obj(self):
self.mock_api_client(200) """
self.mock_node_retrieval(self.program.uuid) Verify that the publisher serializes data required to publish programs.
self.mock_node_edit(200) """
program_before = ProgramFactory() self.mock_api_client()
publisher = MarketingSitePublisher(program_before)
self.mock_add_alias() actual = self.publisher.serialize_obj(self.obj)
self.mock_delete_alias() expected = {
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}): 'field_uuid': str(self.obj.uuid),
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}): 'author': {'id': self.user_id},
with mock.patch.object(MarketingSitePublisher, '_get_delete_alias_url', return_value='/foo'): 'status': 1,
publisher.publish_program(self.program) 'title': self.obj.title,
self.assert_responses_call_count(8) '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 @responses.activate
def test_get_alias_form(self): def test_update_node_alias(self):
self.mock_api_client(200) """
self.mock_node_retrieval(self.program.uuid) Verify that the publisher attempts to create a new alias when necessary
self.mock_node_edit(200) and deletes an old alias, if one existed, and that appropriate exceptions
publisher = MarketingSitePublisher() 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.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() self.mock_get_alias_form()
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}): self.mock_add_alias(status=500)
publisher.publish_program(self.program)
self.assert_responses_call_count(8)
@responses.activate with pytest.raises(AliasCreateError):
def test_get_delete_form(self): self.publisher.update_node_alias(self.obj, self.node_id, None)
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)
@responses.activate responses.reset()
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.activate # A previous object is provided, but the marketing slug hasn't changed.
def test_get_delete_form_failed(self): # Neither alias creation nor alias deletion should occur.
self.mock_api_client(200) self.mock_api_client()
self.mock_node_retrieval(self.program.uuid)
self.mock_node_edit(200) self.publisher.update_node_alias(self.obj, self.node_id, self.obj)
program_before = ProgramFactory()
publisher = MarketingSitePublisher(program_before) 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_add_alias()
self.mock_get_delete_form(program_before.marketing_slug, 500) self.mock_get_delete_form(previous_obj.marketing_slug)
with mock.patch.object(MarketingSitePublisher, '_get_headers', return_value={}): self.mock_delete_alias()
with mock.patch.object(MarketingSitePublisher, '_get_form_build_id_and_form_token', return_value={}):
with self.assertRaises(ProgramPublisherException):
publisher.publish_program(self.program)
@responses.activate self.publisher.update_node_alias(self.obj, self.node_id, previous_obj)
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)
@responses.activate assert any('/add' in call.request.url for call in responses.calls)
def test_publish_unmodified_program(self): assert any('/list/{}'.format(previous_obj.marketing_slug) in call.request.url for call in responses.calls)
self.mock_api_client(200)
publisher = MarketingSitePublisher(self.program)
publisher.publish_program(self.program)
self.assert_responses_call_count(0)
@responses.activate responses.reset()
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.activate # Same scenario, but this time a non-200 status code is returned during
def test_publish_program_no_credential(self): # alias deletion. An exception should be raised.
self.program.partner.marketing_site_api_password = None self.mock_api_client()
self.program.partner.marketing_site_api_username = None self.mock_get_alias_form()
publisher = MarketingSitePublisher() self.mock_add_alias()
with self.assertRaises(ProgramPublisherException): self.mock_get_delete_form(previous_obj.marketing_slug)
publisher.publish_program(self.program) self.mock_delete_alias(status=500)
self.assert_responses_call_count(0)
@responses.activate with pytest.raises(AliasDeleteError):
def test_publish_delete_program(self): self.publisher.update_node_alias(self.obj, self.node_id, previous_obj)
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)
@responses.activate def test_alias(self):
def test_publish_delete_non_existent_program(self): """
self.mock_api_client(200) Verify that aliases are constructed correctly.
self.mock_node_retrieval(self.program.uuid, exists=False) """
publisher = MarketingSitePublisher() actual = self.publisher.alias(self.obj)
publisher.delete_program(self.program) expected = '{type}/{slug}'.format(type=self.obj.type.slug, slug=self.obj.marketing_slug)
self.assert_responses_call_count(4)
@responses.activate assert actual == expected
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)
# 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): ...@@ -28,14 +28,14 @@ class CourseRunSelectionAdmin(UpdateView):
context = super(CourseRunSelectionAdmin, self).get_context_data(**kwargs) context = super(CourseRunSelectionAdmin, self).get_context_data(**kwargs)
context.update({ context.update({
'program_id': self.object.id, 'program_id': self.object.id,
'title': _('Change program excluded course runs') 'title': _('Update excluded course runs')
}) })
return context return context
raise Http404 raise Http404
def form_valid(self, form): def form_valid(self, form):
self.object = form.save() 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) messages.add_message(self.request, messages.SUCCESS, message)
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
...@@ -201,6 +201,13 @@ msgid "Partners" ...@@ -201,6 +201,13 @@ msgid "Partners"
msgstr "" msgstr ""
#: apps/course_metadata/admin.py #: 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" msgid "eligible for one-click purchase"
msgstr "" msgstr ""
...@@ -217,12 +224,6 @@ msgstr "" ...@@ -217,12 +224,6 @@ msgstr ""
msgid "Included course runs" msgid "Included course runs"
msgstr "" 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 #: apps/course_metadata/choices.py apps/publisher/choices.py
msgid "Published" msgid "Published"
msgstr "" msgstr ""
...@@ -440,7 +441,11 @@ msgid "Allow courses in this program to be purchased in a single transaction" ...@@ -440,7 +441,11 @@ msgid "Allow courses in this program to be purchased in a single transaction"
msgstr "" msgstr ""
#: apps/course_metadata/views.py #: 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 "" msgstr ""
#: apps/publisher/api/serializers.py #: apps/publisher/api/serializers.py
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
...@@ -242,6 +242,21 @@ msgid "Partners" ...@@ -242,6 +242,21 @@ msgid "Partners"
msgstr "Pärtnérs Ⱡ'σяєм ιρѕυм ∂#" msgstr "Pärtnérs Ⱡ'σяєм ιρѕυм ∂#"
#: apps/course_metadata/admin.py #: 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" msgid "eligible for one-click purchase"
msgstr "élïgïßlé för öné-çlïçk pürçhäsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#" msgstr "élïgïßlé för öné-çlïçk pürçhäsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢т#"
...@@ -258,20 +273,6 @@ msgstr "Nö Ⱡ'σя#" ...@@ -258,20 +273,6 @@ msgstr "Nö Ⱡ'σя#"
msgid "Included course runs" msgid "Included course runs"
msgstr "Ìnçlüdéd çöürsé rüns Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, #" 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 #: apps/course_metadata/choices.py apps/publisher/choices.py
msgid "Published" msgid "Published"
msgstr "Püßlïshéd Ⱡ'σяєм ιρѕυм ∂σł#" msgstr "Püßlïshéd Ⱡ'σяєм ιρѕυм ∂σł#"
...@@ -551,9 +552,14 @@ msgstr "" ...@@ -551,9 +552,14 @@ msgstr ""
" ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #" " ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя #"
#: apps/course_metadata/views.py #: 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 "" 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 #: apps/publisher/api/serializers.py
#, python-brace-format #, python-brace-format
......
...@@ -7,7 +7,7 @@ msgid "" ...@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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