Commit 24ee5a68 by Eric Fischer

Merge pull request #10490 from edx/release

Release 4 November 2015 to master
parents e46b4396 ae7b44bb
...@@ -1377,7 +1377,7 @@ class CourseEnrollment(models.Model): ...@@ -1377,7 +1377,7 @@ class CourseEnrollment(models.Model):
# If it is after the refundable cutoff date they should not be refunded. # If it is after the refundable cutoff date they should not be refunded.
refund_cutoff_date = self.refund_cutoff_date() refund_cutoff_date = self.refund_cutoff_date()
if refund_cutoff_date and datetime.now() > refund_cutoff_date: if refund_cutoff_date and datetime.now(UTC) > refund_cutoff_date:
return False return False
course_mode = CourseMode.mode_for_course(self.course_id, 'verified') course_mode = CourseMode.mode_for_course(self.course_id, 'verified')
...@@ -1400,7 +1400,7 @@ class CourseEnrollment(models.Model): ...@@ -1400,7 +1400,7 @@ class CourseEnrollment(models.Model):
self.course_overview.start.replace(tzinfo=None) self.course_overview.start.replace(tzinfo=None)
) )
return refund_window_start_date + EnrollmentRefundConfiguration.current().refund_window return refund_window_start_date.replace(tzinfo=UTC) + EnrollmentRefundConfiguration.current().refund_window
@property @property
def username(self): def username(self):
......
...@@ -113,10 +113,10 @@ class RefundableTest(SharedModuleStoreTestCase): ...@@ -113,10 +113,10 @@ class RefundableTest(SharedModuleStoreTestCase):
self.assertTrue(self.enrollment.refundable()) self.assertTrue(self.enrollment.refundable())
with patch('student.models.CourseEnrollment.refund_cutoff_date') as cutoff_date: with patch('student.models.CourseEnrollment.refund_cutoff_date') as cutoff_date:
cutoff_date.return_value = datetime.now() - timedelta(days=1) cutoff_date.return_value = datetime.now(pytz.UTC) - timedelta(minutes=5)
self.assertFalse(self.enrollment.refundable()) self.assertFalse(self.enrollment.refundable())
cutoff_date.return_value = datetime.now() + timedelta(days=1) cutoff_date.return_value = datetime.now(pytz.UTC) + timedelta(minutes=5)
self.assertTrue(self.enrollment.refundable()) self.assertTrue(self.enrollment.refundable())
@ddt.data( @ddt.data(
...@@ -132,7 +132,7 @@ class RefundableTest(SharedModuleStoreTestCase): ...@@ -132,7 +132,7 @@ class RefundableTest(SharedModuleStoreTestCase):
""" """
Assert that the later date is used with the configurable refund period in calculating the returned cutoff date. Assert that the later date is used with the configurable refund period in calculating the returned cutoff date.
""" """
now = datetime.now().replace(microsecond=0) now = datetime.now(pytz.UTC).replace(microsecond=0)
order_date = now + order_date_delta order_date = now + order_date_delta
course_start = now + course_start_delta course_start = now + course_start_delta
expected_date = now + expected_date_delta expected_date = now + expected_date_delta
......
...@@ -11,6 +11,7 @@ from django.conf import settings ...@@ -11,6 +11,7 @@ from django.conf import settings
from student.tests.factories import UserFactory, CourseEnrollmentFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory
from student.models import CourseEnrollment from student.models import CourseEnrollment
from student.helpers import DISABLE_UNENROLL_CERT_STATES
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -38,7 +39,10 @@ class TestStudentDashboardUnenrollments(ModuleStoreTestCase): ...@@ -38,7 +39,10 @@ class TestStudentDashboardUnenrollments(ModuleStoreTestCase):
def mock_cert(self, _user, _course_overview, _course_mode): # pylint: disable=unused-argument def mock_cert(self, _user, _course_overview, _course_mode): # pylint: disable=unused-argument
""" Return a preset certificate status. """ """ Return a preset certificate status. """
if self.cert_status is not None: if self.cert_status is not None:
return {'status': self.cert_status} return {
'status': self.cert_status,
'can_unenroll': self.cert_status not in DISABLE_UNENROLL_CERT_STATES
}
else: else:
return {} return {}
...@@ -85,3 +89,17 @@ class TestStudentDashboardUnenrollments(ModuleStoreTestCase): ...@@ -85,3 +89,17 @@ class TestStudentDashboardUnenrollments(ModuleStoreTestCase):
course_enrollment.assert_called_with(self.user, self.course.id) course_enrollment.assert_called_with(self.user, self.course.id)
else: else:
course_enrollment.assert_not_called() course_enrollment.assert_not_called()
def test_no_cert_status(self):
""" Assert that the dashboard loads when cert_status is None."""
with patch('student.views.cert_info', return_value=None):
response = self.client.get(reverse('dashboard'))
self.assertEqual(response.status_code, 200)
def test_cant_unenroll_status(self):
""" Assert that the dashboard loads when cert_status does not allow for unenrollment"""
with patch('certificates.models.certificate_status_for_student', return_value={'status': 'ready'}):
response = self.client.get(reverse('dashboard'))
self.assertEqual(response.status_code, 200)
...@@ -79,6 +79,7 @@ class CourseEndingTest(TestCase): ...@@ -79,6 +79,7 @@ class CourseEndingTest(TestCase):
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False, 'show_survey_button': False,
'can_unenroll': True,
} }
) )
...@@ -91,7 +92,8 @@ class CourseEndingTest(TestCase): ...@@ -91,7 +92,8 @@ class CourseEndingTest(TestCase):
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False, 'show_survey_button': False,
'mode': None, 'mode': None,
'linked_in_url': None 'linked_in_url': None,
'can_unenroll': True,
} }
) )
...@@ -106,7 +108,8 @@ class CourseEndingTest(TestCase): ...@@ -106,7 +108,8 @@ class CourseEndingTest(TestCase):
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67', 'grade': '67',
'mode': 'honor', 'mode': 'honor',
'linked_in_url': None 'linked_in_url': None,
'can_unenroll': False,
} }
) )
...@@ -121,7 +124,8 @@ class CourseEndingTest(TestCase): ...@@ -121,7 +124,8 @@ class CourseEndingTest(TestCase):
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67', 'grade': '67',
'mode': 'verified', 'mode': 'verified',
'linked_in_url': None 'linked_in_url': None,
'can_unenroll': False,
} }
) )
...@@ -143,7 +147,8 @@ class CourseEndingTest(TestCase): ...@@ -143,7 +147,8 @@ class CourseEndingTest(TestCase):
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67', 'grade': '67',
'mode': 'honor', 'mode': 'honor',
'linked_in_url': None 'linked_in_url': None,
'can_unenroll': False,
} }
) )
...@@ -162,7 +167,8 @@ class CourseEndingTest(TestCase): ...@@ -162,7 +167,8 @@ class CourseEndingTest(TestCase):
'survey_url': survey_url, 'survey_url': survey_url,
'grade': '67', 'grade': '67',
'mode': 'honor', 'mode': 'honor',
'linked_in_url': None 'linked_in_url': None,
'can_unenroll': True,
} }
) )
...@@ -181,21 +187,22 @@ class CourseEndingTest(TestCase): ...@@ -181,21 +187,22 @@ class CourseEndingTest(TestCase):
'show_survey_button': False, 'show_survey_button': False,
'grade': '67', 'grade': '67',
'mode': 'honor', 'mode': 'honor',
'linked_in_url': None 'linked_in_url': None,
'can_unenroll': True,
} }
) )
# test when the display is unavailable or notpassing, we get the correct results out # test when the display is unavailable or notpassing, we get the correct results out
course2.certificates_display_behavior = 'early_no_info' course2.certificates_display_behavior = 'early_no_info'
cert_status = {'status': 'unavailable'} cert_status = {'status': 'unavailable'}
self.assertIsNone(_cert_info(user, course2, cert_status, course_mode)) self.assertEqual(_cert_info(user, course2, cert_status, course_mode), {})
cert_status = { cert_status = {
'status': 'notpassing', 'grade': '67', 'status': 'notpassing', 'grade': '67',
'download_url': download_url, 'download_url': download_url,
'mode': 'honor' 'mode': 'honor'
} }
self.assertIsNone(_cert_info(user, course2, cert_status, course_mode)) self.assertEqual(_cert_info(user, course2, cert_status, course_mode), {})
@ddt.ddt @ddt.ddt
...@@ -1033,6 +1040,32 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin): ...@@ -1033,6 +1040,32 @@ class DashboardTestXSeriesPrograms(ModuleStoreTestCase, ProgramsApiConfigMixin):
else: else:
self.assertIn('xseries-border-btn', response.content) self.assertIn('xseries-border-btn', response.content)
@patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False})
@ddt.data((-2, -1), (-1, 1), (1, 2))
@ddt.unpack
def test_start_end_offsets(self, start_days_offset, end_days_offset):
"""Test that the xseries upsell messaging displays whether the course
has not yet started, is in session, or has already ended.
"""
self.course_1.start = datetime.now(pytz.UTC) + timedelta(days=start_days_offset)
self.course_1.end = datetime.now(pytz.UTC) + timedelta(days=end_days_offset)
self.update_course(self.course_1, self.user.id)
CourseEnrollment.enroll(self.user, self.course_1.id, mode='verified')
self.client.login(username="jack", password="test")
self.create_config(enabled=True, enable_student_dashboard=True)
with patch(
'student.views.get_course_programs_for_dashboard',
return_value=self._create_program_data([(self.course_1.id, 'active')])
) as mock_get_programs:
response = self.client.get(reverse('dashboard'))
# ensure that our course id was included in the API call regardless of start/end dates
__, course_ids = mock_get_programs.call_args[0]
self.assertEqual(list(course_ids), [self.course_1.id])
# count total courses appearing on student dashboard
self._assert_responses(response, 1)
@ddt.data( @ddt.data(
('unpublished', 'unpublished', 'unpublished', 0), ('unpublished', 'unpublished', 'unpublished', 0),
('active', 'unpublished', 'unpublished', 1), ('active', 'unpublished', 'unpublished', 1),
......
...@@ -202,6 +202,7 @@ def cert_info(user, course_overview, course_mode): ...@@ -202,6 +202,7 @@ def cert_info(user, course_overview, course_mode):
'show_survey_button': bool 'show_survey_button': bool
'survey_url': url, only if show_survey_button is True 'survey_url': url, only if show_survey_button is True
'grade': if status is not 'processing' 'grade': if status is not 'processing'
'can_unenroll': if status allows for unenrollment
""" """
if not course_overview.may_certify(): if not course_overview.may_certify():
return {} return {}
...@@ -302,6 +303,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa ...@@ -302,6 +303,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
'show_disabled_download_button': False, 'show_disabled_download_button': False,
'show_download_url': False, 'show_download_url': False,
'show_survey_button': False, 'show_survey_button': False,
'can_unenroll': True
} }
if cert_status is None: if cert_status is None:
...@@ -310,7 +312,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa ...@@ -310,7 +312,7 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing') is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing')
if course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status: if course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status:
return None return {}
status = template_state.get(cert_status['status'], default_status) status = template_state.get(cert_status['status'], default_status)
...@@ -319,7 +321,8 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa ...@@ -319,7 +321,8 @@ def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disa
'show_download_url': status == 'ready', 'show_download_url': status == 'ready',
'show_disabled_download_button': status == 'generating', 'show_disabled_download_button': status == 'generating',
'mode': cert_status.get('mode', None), 'mode': cert_status.get('mode', None),
'linked_in_url': None 'linked_in_url': None,
'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES,
} }
if (status in ('generating', 'ready', 'notpassing', 'restricted') and if (status in ('generating', 'ready', 'notpassing', 'restricted') and
...@@ -581,7 +584,7 @@ def dashboard(request): ...@@ -581,7 +584,7 @@ def dashboard(request):
# program-related information on the dashboard view. # program-related information on the dashboard view.
course_programs = {} course_programs = {}
if is_student_dashboard_programs_enabled(): if is_student_dashboard_programs_enabled():
course_programs = _get_course_programs(user, show_courseware_links_for) course_programs = _get_course_programs(user, [enrollment.course_id for enrollment in course_enrollments])
# Construct a dictionary of course mode information # Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict # used to render the course list. We re-use the course modes dict
...@@ -1030,8 +1033,8 @@ def change_enrollment(request, check_access=True): ...@@ -1030,8 +1033,8 @@ def change_enrollment(request, check_access=True):
if not enrollment: if not enrollment:
return HttpResponseBadRequest(_("You are not enrolled in this course")) return HttpResponseBadRequest(_("You are not enrolled in this course"))
certicifate_info = cert_info(user, enrollment.course_overview, enrollment.mode) certificate_info = cert_info(user, enrollment.course_overview, enrollment.mode)
if certicifate_info.get('status') in DISABLE_UNENROLL_CERT_STATES: if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES:
return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course")) return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course"))
CourseEnrollment.unenroll(user, course_id) CourseEnrollment.unenroll(user, course_id)
......
...@@ -245,7 +245,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -245,7 +245,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# Stop the timed exam. # Stop the timed exam.
self.courseware_page.stop_timed_exam() self.courseware_page.stop_timed_exam()
@flaky # TODO fix this SOL-1183 @flaky # TODO fix this SOL-1182
def test_can_add_remove_allowance(self): def test_can_add_remove_allowance(self):
""" """
Make sure that allowances can be added and removed. Make sure that allowances can be added and removed.
...@@ -263,6 +263,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest): ...@@ -263,6 +263,7 @@ class ProctoredExamsTest(BaseInstructorDashboardTest):
# Then I can add Allowance to that exam for a student # Then I can add Allowance to that exam for a student
self.assertTrue(allowance_section.is_add_allowance_button_visible) self.assertTrue(allowance_section.is_add_allowance_button_visible)
@flaky # TODO fix this SOL-1182
def test_can_reset_attempts(self): def test_can_reset_attempts(self):
""" """
Make sure that Exam attempts are visible and can be reset. Make sure that Exam attempts are visible and can be reset.
......
...@@ -11,6 +11,7 @@ from certificates.models import ( ...@@ -11,6 +11,7 @@ from certificates.models import (
BadgeImageConfiguration, BadgeImageConfiguration,
CertificateTemplate, CertificateTemplate,
CertificateTemplateAsset, CertificateTemplateAsset,
GeneratedCertificate,
) )
...@@ -46,8 +47,17 @@ class CertificateTemplateAssetAdmin(admin.ModelAdmin): ...@@ -46,8 +47,17 @@ class CertificateTemplateAssetAdmin(admin.ModelAdmin):
list_display = ('description', '__unicode__') list_display = ('description', '__unicode__')
class GeneratedCertificateAdmin(admin.ModelAdmin):
"""
Django admin customizations for GeneratedCertificate model
"""
search_fields = ('course_id', 'user__username')
list_display = ('id', 'course_id', 'mode', 'user')
admin.site.register(CertificateGenerationConfiguration) admin.site.register(CertificateGenerationConfiguration)
admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin) admin.site.register(CertificateHtmlViewConfiguration, ConfigurationModelAdmin)
admin.site.register(BadgeImageConfiguration) admin.site.register(BadgeImageConfiguration)
admin.site.register(CertificateTemplate, CertificateTemplateAdmin) admin.site.register(CertificateTemplate, CertificateTemplateAdmin)
admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin) admin.site.register(CertificateTemplateAsset, CertificateTemplateAssetAdmin)
admin.site.register(GeneratedCertificate, GeneratedCertificateAdmin)
...@@ -1037,7 +1037,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course ...@@ -1037,7 +1037,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
# New Relic. The suffix is necessary for XModule handlers because the # New Relic. The suffix is necessary for XModule handlers because the
# "handler" in those cases is always just "xmodule_handler". # "handler" in those cases is always just "xmodule_handler".
nr_tx_name = "{}.{}".format(instance.__class__.__name__, handler) nr_tx_name = "{}.{}".format(instance.__class__.__name__, handler)
nr_tx_name += "/{}".format(suffix) if suffix else "" nr_tx_name += "/{}".format(suffix) if (suffix and handler == "xmodule_handler") else ""
newrelic.agent.set_transaction_name(nr_tx_name, group="Python/XBlock/Handler") newrelic.agent.set_transaction_name(nr_tx_name, group="Python/XBlock/Handler")
tracking_context_name = 'module_callback_handler' tracking_context_name = 'module_callback_handler'
......
...@@ -87,6 +87,7 @@ import json ...@@ -87,6 +87,7 @@ import json
% for dashboard_index, enrollment in enumerate(course_enrollments): % for dashboard_index, enrollment in enumerate(course_enrollments):
<% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %> <% show_courseware_link = (enrollment.course_id in show_courseware_links_for) %>
<% cert_status = cert_statuses.get(enrollment.course_id) %> <% cert_status = cert_statuses.get(enrollment.course_id) %>
<% can_unenroll = (not cert_status) or cert_status.get('can_unenroll') %>
<% credit_status = credit_statuses.get(enrollment.course_id) %> <% credit_status = credit_statuses.get(enrollment.course_id) %>
<% show_email_settings = (enrollment.course_id in show_email_settings_for) %> <% show_email_settings = (enrollment.course_id in show_email_settings_for) %>
<% course_mode_info = all_course_modes.get(enrollment.course_id) %> <% course_mode_info = all_course_modes.get(enrollment.course_id) %>
...@@ -96,7 +97,7 @@ import json ...@@ -96,7 +97,7 @@ import json
<% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %> <% course_verification_status = verification_status_by_course.get(enrollment.course_id, {}) %>
<% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %> <% course_requirements = courses_requirements_not_met.get(enrollment.course_id) %>
<% course_program_info = course_programs.get(unicode(enrollment.course_id)) %> <% course_program_info = course_programs.get(unicode(enrollment.course_id)) %>
<%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, course_program_info=course_program_info" /> <%include file = 'dashboard/_dashboard_course_listing.html' args="course_overview=enrollment.course_overview, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option=show_refund_option, is_paid_course=is_paid_course, is_course_blocked=is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, course_program_info=course_program_info" />
% endfor % endfor
</ul> </ul>
......
<%page args="course_overview, enrollment, show_courseware_link, cert_status, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, course_program_info" /> <%page args="course_overview, enrollment, show_courseware_link, cert_status, can_unenroll, credit_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements, dashboard_index, share_settings, course_program_info" />
<%! <%!
import urllib import urllib
...@@ -178,7 +178,7 @@ from student.helpers import ( ...@@ -178,7 +178,7 @@ from student.helpers import (
</a> </a>
<div class="actions-dropdown" id="actions-dropdown-${dashboard_index}" aria-label="${_('Additional Actions Menu')}"> <div class="actions-dropdown" id="actions-dropdown-${dashboard_index}" aria-label="${_('Additional Actions Menu')}">
<ul class="actions-dropdown-list" id="actions-dropdown-list-${dashboard_index}" aria-label="${_('Available Actions')}" role="menu"> <ul class="actions-dropdown-list" id="actions-dropdown-list-${dashboard_index}" aria-label="${_('Available Actions')}" role="menu">
% if cert_status.get('status') not in DISABLE_UNENROLL_CERT_STATES: % if can_unenroll:
<li class="actions-item" id="actions-item-unenroll-${dashboard_index}"> <li class="actions-item" id="actions-item-unenroll-${dashboard_index}">
% if is_paid_course and show_refund_option: % if is_paid_course and show_refund_option:
% if not is_course_blocked: % if not is_course_blocked:
......
"""
Intended to fix any inconsistencies that may arise during the rollout of the CohortMembership model.
Illustration: https://gist.github.com/efischer19/d62f8ee42b7fbfbc6c9a
"""
from django.core.management.base import BaseCommand
from django.db import IntegrityError
from openedx.core.djangoapps.course_groups.models import CourseUserGroup, CohortMembership
class Command(BaseCommand):
"""
Repair any inconsistencies between CourseUserGroup and CohortMembership. To be run after migration 0006.
"""
help = '''
Repairs any potential inconsistencies made in the window between running migrations 0005 and 0006, and deploying
the code changes to enforce use of CohortMembership that go with said migrations.
|commit|: optional argument. If not provided, will dry-run and list number of operations that would be made.
'''
def handle(self, *args, **options):
"""
Execute the command. Since this is designed to fix any issues cause by running pre-CohortMembership code
with the database already migrated to post-CohortMembership state, we will use the pre-CohortMembership
table CourseUserGroup as the canonical source of truth. This way, changes made in the window are persisted.
"""
commit = False
if len(args) == 1:
commit = args[0] == 'commit'
memberships_to_delete = 0
memberships_to_add = 0
# Begin by removing any data in CohortMemberships that does not match CourseUserGroups data
for membership in CohortMembership.objects.all():
try:
CourseUserGroup.objects.get(
group_type=CourseUserGroup.COHORT,
users__id=membership.user.id,
course_id=membership.course_id,
id=membership.course_user_group.id
)
except CourseUserGroup.DoesNotExist:
memberships_to_delete += 1
if commit:
membership.delete()
# Now we can add any CourseUserGroup data that is missing a backing CohortMembership
for course_group in CourseUserGroup.objects.filter(group_type=CourseUserGroup.COHORT):
for user in course_group.users.all():
try:
CohortMembership.objects.get(
user=user,
course_id=course_group.course_id,
course_user_group_id=course_group.id
)
except CohortMembership.DoesNotExist:
memberships_to_add += 1
if commit:
membership = CohortMembership(
course_user_group=course_group,
user=user,
course_id=course_group.course_id
)
try:
membership.save()
except IntegrityError: # If the user is in multiple cohorts, we arbitrarily choose between them
# In this case, allow the pre-existing entry to be "correct"
course_group.users.remove(user)
user.course_groups.remove(course_group)
print '{} CohortMemberships did not match the CourseUserGroup table and will be deleted'.format(
memberships_to_delete
)
print '{} CourseUserGroup users do not have a CohortMembership; one will be added if it is valid'.format(
memberships_to_add
)
if commit:
print 'Changes have been made and saved.'
else:
print 'Dry run, changes have not been saved. Run again with "commit" argument to save changes'
"""
Script for removing users with multiple cohorts of a course from cohorts
to ensure user's uniqueness for a course cohorts
"""
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from django.db.models import Count
from openedx.core.djangoapps.course_groups.models import CourseUserGroup
class Command(BaseCommand):
"""
Remove users with multiple cohorts of a course from all cohorts
"""
help = 'Remove all users from multiple cohorts (except one) of each course'
def handle(self, *args, **options):
"""
Execute the command
"""
# Get entries of cohorts which have same user added multiple times for a single course
multiple_objects_cohorts = CourseUserGroup.objects.filter(group_type=CourseUserGroup.COHORT).\
values_list('users', 'course_id').annotate(user_count=Count('users')).filter(user_count__gt=1).\
order_by('users')
multiple_objects_cohorts_count = multiple_objects_cohorts.count()
multiple_course_cohorts_users = set(multiple_objects_cohorts.values_list('users', flat=True))
users_failed_to_cleanup = []
for user in User.objects.filter(id__in=multiple_course_cohorts_users):
print u"Removing user with id '{0}' from cohort groups".format(user.id)
try:
# remove user from only cohorts
user.course_groups.remove(*user.course_groups.filter(group_type=CourseUserGroup.COHORT))
except AttributeError as err:
users_failed_to_cleanup.append(user.email)
print u"Failed to remove user with id {0} from cohort groups, error: {1}".format(user.id, err)
print "=" * 80
print u"=" * 30 + u"> Cohorts summary"
print(
u"Total number of CourseUserGroup of type '{0}' with multiple users: {1}".format(
CourseUserGroup.COHORT, multiple_objects_cohorts_count
)
)
print(
u"Total number of unique users with multiple course cohorts: {0}".format(
len(multiple_course_cohorts_users)
)
)
print(
u"Users which failed on cohorts cleanup [{0}]: [{1}]".format(
len(users_failed_to_cleanup), (', '.join(users_failed_to_cleanup))
)
)
print "=" * 80
"""
Test for the post-migration fix commands that are included with this djangoapp
"""
from django.core.management import call_command
from django.test.client import RequestFactory
from openedx.core.djangoapps.course_groups.views import cohort_handler
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_name
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from openedx.core.djangoapps.course_groups.models import CohortMembership
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
class TestPostMigrationFix(ModuleStoreTestCase):
"""
Base class for testing post-migration fix commands
"""
def setUp(self):
"""
setup course, user and request for tests
"""
super(TestPostMigrationFix, self).setUp()
self.course1 = CourseFactory.create()
self.course2 = CourseFactory.create()
self.user1 = UserFactory(is_staff=True)
self.user2 = UserFactory(is_staff=True)
self.request = RequestFactory().get("dummy_url")
self.request.user = self.user1
def test_post_cohortmembership_fix(self):
"""
Test that changes made *after* migration, but *before* turning on new code are handled properly
"""
# First, we're going to simulate some problem states that can arise during this window
config_course_cohorts(self.course1, is_cohorted=True, auto_cohorts=["Course1AutoGroup1", "Course1AutoGroup2"])
# Get the cohorts from the courses, which will cause auto cohorts to be created
cohort_handler(self.request, unicode(self.course1.id))
course_1_auto_cohort_1 = get_cohort_by_name(self.course1.id, "Course1AutoGroup1")
course_1_auto_cohort_2 = get_cohort_by_name(self.course1.id, "Course1AutoGroup2")
# When migrations were first run, the users were assigned to CohortMemberships correctly
membership1 = CohortMembership(
course_id=course_1_auto_cohort_1.course_id,
user=self.user1,
course_user_group=course_1_auto_cohort_1
)
membership1.save()
membership2 = CohortMembership(
course_id=course_1_auto_cohort_1.course_id,
user=self.user2,
course_user_group=course_1_auto_cohort_1
)
membership2.save()
# But before CohortMembership code was turned on, some changes were made:
course_1_auto_cohort_2.users.add(self.user1) # user1 is now in 2 cohorts in the same course!
course_1_auto_cohort_2.users.add(self.user2)
course_1_auto_cohort_1.users.remove(self.user2) # and user2 was moved, but no one told CohortMembership!
# run the post-CohortMembership command, dry-run
call_command('post_cohort_membership_fix')
# Verify nothing was changed in dry-run mode.
self.assertEqual(self.user1.course_groups.count(), 2) # CourseUserGroup has 2 entries for user1
self.assertEqual(CohortMembership.objects.get(user=self.user2).course_user_group.name, 'Course1AutoGroup1')
user2_cohorts = list(self.user2.course_groups.values_list('name', flat=True))
self.assertEqual(user2_cohorts, ['Course1AutoGroup2']) # CourseUserGroup and CohortMembership disagree
# run the post-CohortMembership command, and commit it
call_command('post_cohort_membership_fix', 'commit')
# verify that both databases agree about the (corrected) state of the memberships
self.assertEqual(self.user1.course_groups.count(), 1)
self.assertEqual(CohortMembership.objects.filter(user=self.user1).count(), 1)
self.assertEqual(self.user2.course_groups.count(), 1)
self.assertEqual(CohortMembership.objects.filter(user=self.user2).count(), 1)
self.assertEqual(CohortMembership.objects.get(user=self.user2).course_user_group.name, 'Course1AutoGroup2')
user2_cohorts = list(self.user2.course_groups.values_list('name', flat=True))
self.assertEqual(user2_cohorts, ['Course1AutoGroup2'])
"""
Tests for cleanup of users which are added in multiple cohorts of a course
"""
from django.core.exceptions import MultipleObjectsReturned
from django.core.management import call_command
from django.test.client import RequestFactory
from openedx.core.djangoapps.course_groups.views import cohort_handler
from openedx.core.djangoapps.course_groups.cohorts import get_cohort, get_cohort_by_name
from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
class TestMultipleCohortUsers(ModuleStoreTestCase):
"""
Base class for testing users with multiple cohorts
"""
def setUp(self):
"""
setup course, user and request for tests
"""
super(TestMultipleCohortUsers, self).setUp()
self.course1 = CourseFactory.create()
self.course2 = CourseFactory.create()
self.user1 = UserFactory(is_staff=True)
self.user2 = UserFactory(is_staff=True)
self.request = RequestFactory().get("dummy_url")
self.request.user = self.user1
def test_users_with_multiple_cohorts_cleanup(self):
"""
Test that user which have been added in multiple cohorts of a course,
can get cohorts without error after running cohorts cleanup command
"""
# set two auto_cohort_groups for both courses
config_course_cohorts(
self.course1, is_cohorted=True, auto_cohorts=["Course1AutoGroup1", "Course1AutoGroup2"]
)
config_course_cohorts(
self.course2, is_cohorted=True, auto_cohorts=["Course2AutoGroup1", "Course2AutoGroup2"]
)
# get the cohorts from the courses, which will cause auto cohorts to be created
cohort_handler(self.request, unicode(self.course1.id))
cohort_handler(self.request, unicode(self.course2.id))
course_1_auto_cohort_1 = get_cohort_by_name(self.course1.id, "Course1AutoGroup1")
course_1_auto_cohort_2 = get_cohort_by_name(self.course1.id, "Course1AutoGroup2")
course_2_auto_cohort_1 = get_cohort_by_name(self.course2.id, "Course2AutoGroup1")
# forcefully add user1 in two auto cohorts
course_1_auto_cohort_1.users.add(self.user1)
course_1_auto_cohort_2.users.add(self.user1)
# forcefully add user2 in auto cohorts of both courses
course_1_auto_cohort_1.users.add(self.user2)
course_2_auto_cohort_1.users.add(self.user2)
# now check that when user1 goes on discussion page and tries to get
# cohorts 'MultipleObjectsReturned' exception is returned
with self.assertRaises(MultipleObjectsReturned):
get_cohort(self.user1, self.course1.id)
# also check that user 2 can go on discussion page of both courses
# without any exception
get_cohort(self.user2, self.course1.id)
get_cohort(self.user2, self.course2.id)
# call command to remove users added in multiple cohorts of a course
# are removed from all cohort groups
call_command('remove_users_from_multiple_cohorts')
# check that only user1 (with multiple cohorts) is removed from cohorts
# and user2 is still in auto cohorts of both course after running
# 'remove_users_from_multiple_cohorts' management command
self.assertEqual(self.user1.course_groups.count(), 0)
self.assertEqual(self.user2.course_groups.count(), 2)
user2_cohorts = list(self.user2.course_groups.values_list('name', flat=True))
self.assertEqual(user2_cohorts, ['Course1AutoGroup1', 'Course2AutoGroup1'])
# now check that user1 can get cohorts in which he is added
response = cohort_handler(self.request, unicode(self.course1.id))
self.assertEqual(response.status_code, 200)
#!/bin/bash
if [ $# -eq 0 ]; then
echo "$0: usage: rerun_0006.sh <arguments>. At minimum, '--settings=<environment>' is expected."
exit 1
fi
./manage.py lms migrate course_groups 0005 --fake "$@"
./manage.py lms migrate course_groups 0006 "$@"
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