%endif
% if not pages_and_resources_mfe_enabled:
diff --git a/common/djangoapps/course_modes/tests/test_admin.py b/common/djangoapps/course_modes/tests/test_admin.py
index 52640dc52f..25b6558d24 100644
--- a/common/djangoapps/course_modes/tests/test_admin.py
+++ b/common/djangoapps/course_modes/tests/test_admin.py
@@ -54,7 +54,7 @@ class AdminCourseModePageTest(ModuleStoreTestCase):
'_expiration_datetime_1': expiration.time(),
}
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
# Create a new course mode from django admin page
response = self.client.post(reverse('admin:course_modes_coursemode_add'), data=data)
diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py
index abb98dcaff..fb2999eca9 100644
--- a/common/djangoapps/student/auth.py
+++ b/common/djangoapps/student/auth.py
@@ -93,9 +93,16 @@ def get_user_permissions(user, course_key, org=None):
return all_perms
if course_key and user_has_role(user, CourseInstructorRole(course_key)):
return all_perms
- # Limited Course Staff does not have access to Studio.
+ # HACK: Limited Staff should not have studio read access. However, since many LMS views depend on the
+ # `has_course_author_access` check and `course_author_access_required` decorator, we have to allow write access
+ # until the permissions become more granular. For example, there could be STUDIO_VIEW_COHORTS and
+ # STUDIO_EDIT_COHORTS specifically for the cohorts endpoint, which is used to display the "Cohorts" tab of the
+ # Instructor Dashboard.
+ # The permissions matrix from the RBAC project (https://github.com/openedx/platform-roadmap/issues/246) shows that
+ # the LMS and Studio permissions will be separated as a part of this project. Once this is done (and this code is
+ # not removed during its implementation), we can replace the Limited Staff permissions with more granular ones.
if course_key and user_has_role(user, CourseLimitedStaffRole(course_key)):
- return STUDIO_NO_PERMISSIONS
+ return STUDIO_EDIT_CONTENT
# Staff have all permissions except EDIT_ROLES:
if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))):
return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
diff --git a/common/djangoapps/student/handlers.py b/common/djangoapps/student/handlers.py
index 6dc841f98f..d7d5eeee3c 100644
--- a/common/djangoapps/student/handlers.py
+++ b/common/djangoapps/student/handlers.py
@@ -8,6 +8,9 @@ from openedx_events.event_bus import get_producer
from openedx_events.learning.signals import (
COURSE_UNENROLLMENT_COMPLETED,
)
+from openedx.core.lib.events import determine_producer_config_for_signal_and_topic
+import logging
+log = logging.getLogger(__name__)
@receiver(COURSE_UNENROLLMENT_COMPLETED)
@@ -15,10 +18,16 @@ def course_unenrollment_receiver(sender, signal, **kwargs):
"""
Removes user notification preference when user un-enrolls from the course
"""
+ topic = getattr(settings, "EVENT_BUS_ENROLLMENT_LIFECYCLE_TOPIC", "course-unenrollment-lifecycle")
+ producer_config_setting = determine_producer_config_for_signal_and_topic(COURSE_UNENROLLMENT_COMPLETED, topic)
+ if producer_config_setting is True:
+ log.info("Producing unenrollment-event event via config")
+ return
if settings.FEATURES.get("ENABLE_SEND_ENROLLMENT_EVENTS_OVER_BUS"):
+ log.info("Producing unenrollment-event event via manual send")
get_producer().send(
signal=COURSE_UNENROLLMENT_COMPLETED,
- topic=getattr(settings, "EVENT_BUS_ENROLLMENT_LIFECYCLE_TOPIC", "course-unenrollment-lifecycle"),
+ topic=topic,
event_key_field='enrollment.course.course_key',
event_data={'enrollment': kwargs.get('enrollment')},
event_metadata=kwargs.get('metadata')
diff --git a/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py b/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py
new file mode 100644
index 0000000000..6c13392d22
--- /dev/null
+++ b/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py
@@ -0,0 +1,139 @@
+""" Management command to add user emails data on Braze. """
+import logging
+import time
+
+from braze.client import BrazeClient
+from braze.exceptions import BrazeClientError
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
+
+from common.djangoapps.util.query import use_read_replica_if_available
+
+User = get_user_model()
+
+MARKETING_EMAIL_ATTRIBUTE_NAME = 'is_marketable'
+TRACK_USER_COMPONENT_CHUNK_SIZE = 75
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ """
+ Command to add user email address on Braze against a user_id.
+ Example usage:
+ $ ./manage.py lms populate_user_emails_on_braze
+ """
+ help = """
+ Updates user accounts with email addresses on braze.
+ """
+
+ def add_arguments(self, parser):
+ """
+ Function to get command arguments
+ """
+ parser.add_argument(
+ '--batch-delay',
+ type=float,
+ dest='batch_delay',
+ default=0.5,
+ help='Time delay in seconds between each iteration'
+ )
+ parser.add_argument(
+ '--batch-size',
+ type=int,
+ dest='batch_size',
+ default=10000,
+ help='Batch size'
+ )
+ parser.add_argument(
+ '--starting-user-id',
+ type=int,
+ dest='starting_user_id',
+ default=0,
+ help='Starting user id to process a specific batch of users. '
+ 'Both start and end id should be provided.',
+ )
+ parser.add_argument(
+ '--ending-user-id',
+ type=int,
+ dest='ending_user_id',
+ default=0,
+ help='Ending user id (inclusive) to process a specific batch of users. '
+ 'Both start and end id should be provided.',
+ )
+
+ def __init__(self):
+ super().__init__()
+ self.braze_client = BrazeClient(
+ api_key=settings.EDX_BRAZE_API_KEY,
+ api_url=settings.EDX_BRAZE_API_SERVER,
+ app_id='',
+ )
+
+ @staticmethod
+ def _chunks(users, chunk_size=TRACK_USER_COMPONENT_CHUNK_SIZE):
+ """
+ Yields successive chunks of users. The size of each chunk is determined by
+ TRACK_USER_COMPONENT_CHUNK_SIZE which is set to 75.
+ Reference: https://www.braze.com/docs/api/endpoints/user_data/post_user_track/
+ """
+ for index in range(0, len(users), chunk_size):
+ yield users[index:index + chunk_size]
+
+ def _get_user_batch(self, batch_start_id, batch_end_id):
+ """
+ This returns the batch of users.
+ """
+ query = User.objects.filter(
+ id__gte=batch_start_id, id__lt=batch_end_id,
+ ).select_related('profile').values_list(
+ 'id', 'email', named=True,
+ ).order_by('id')
+
+ return use_read_replica_if_available(query)
+
+ def _update_braze_attributes(self, users):
+ """
+ Sends Braze API request to update user account.
+ Fields sent using the API include:
+ - external_id (user_id)
+ - email
+ """
+ attributes = []
+ for user in users:
+ attributes.append(
+ {
+ "external_id": user.id,
+ "email": user.email,
+ }
+ )
+
+ try:
+ self.braze_client.track_user(attributes=attributes)
+ except BrazeClientError as error:
+ logger.error(f'Failed to update attributes. Error: {error}')
+
+ def handle(self, *args, **options):
+ """
+ Handler to run the command.
+ """
+ sleep_time = options['batch_delay']
+ batch_size = options['batch_size']
+ starting_user_id = options['starting_user_id']
+ ending_user_id = options['ending_user_id']
+
+ all_users_query = use_read_replica_if_available(User.objects)
+ total_users_count = ending_user_id if ending_user_id else all_users_query.count()
+
+ for index in range(starting_user_id, total_users_count + 1, batch_size):
+ users = self._get_user_batch(index, index + batch_size)
+ logger.info(f'Processing users with user ids in {index} - {(index + batch_size) - 1} range')
+
+ # Force evaluating the query to avoid multiple hits to db
+ # when we evaluate the chunks.
+ evaluated_users = list(users)
+ for user_chunk in self._chunks(evaluated_users):
+ self._update_braze_attributes(user_chunk)
+
+ time.sleep(sleep_time)
diff --git a/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py b/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py
new file mode 100644
index 0000000000..8d8ad31c4c
--- /dev/null
+++ b/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py
@@ -0,0 +1,91 @@
+"""
+Unittests for populate_marketing_opt_in_user_attribute management command.
+"""
+from unittest.mock import patch, MagicMock
+
+from braze.exceptions import BrazeClientError
+from django.core.management import call_command
+from django.test import TestCase
+from testfixtures import LogCapture
+
+from common.djangoapps.student.tests.factories import UserFactory
+from openedx.core.djangolib.testing.utils import skip_unless_lms
+
+LOGGER_NAME = 'common.djangoapps.student.management.commands.populate_users_emails_on_braze'
+
+
+@skip_unless_lms
+class TestPopulateUsersEmailsOnBraze(TestCase):
+ """
+ Tests for PopulateUsersEmailsOnBraze management command.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ for index in range(15):
+ user = UserFactory()
+
+ @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user')
+ def test_command_updates_users_on_braze(self, track_user):
+ """
+ Test that Braze API is called successfully for all users
+ """
+ track_user.return_value = MagicMock()
+ call_command('populate_users_emails_on_braze', batch_delay=0)
+ assert track_user.called
+
+ @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user')
+ def test_logs_for_success(self, track_user):
+ """
+ Test logs for a successful run of updating user accounts on Braze
+ """
+ track_user.return_value = MagicMock()
+ with LogCapture(LOGGER_NAME) as log:
+ call_command(
+ 'populate_users_emails_on_braze',
+ batch_size=5,
+ batch_delay=0,
+ )
+ log.check(
+ (LOGGER_NAME, 'INFO', 'Processing users with user ids in 0 - 4 range'),
+ (LOGGER_NAME, 'INFO', 'Processing users with user ids in 5 - 9 range'),
+ (LOGGER_NAME, 'INFO', 'Processing users with user ids in 10 - 14 range'),
+ (LOGGER_NAME, 'INFO', 'Processing users with user ids in 15 - 19 range'),
+ )
+
+ @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user')
+ def test_logs_for_failure(self, track_user):
+ """
+ Test logs for when the update to Braze fails
+ """
+ track_user.side_effect = BrazeClientError('Update to attributes failed.')
+ with LogCapture(LOGGER_NAME) as log:
+ call_command(
+ 'populate_users_emails_on_braze',
+ batch_size=5,
+ batch_delay=0,
+ )
+ log.check_present(
+ (LOGGER_NAME, 'ERROR', 'Failed to update attributes. Error: Update to attributes failed.'),
+ )
+
+ @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user')
+ def test_running_a_specific_batch(self, track_user):
+ """
+ Test running command for a specific batch of users
+ """
+ track_user.return_value = MagicMock()
+ with LogCapture(LOGGER_NAME) as log:
+ call_command(
+ 'populate_users_emails_on_braze',
+ batch_size=5,
+ batch_delay=0,
+ starting_user_id=2,
+ ending_user_id=13,
+ )
+ log.check(
+ (LOGGER_NAME, 'INFO', 'Processing users with user ids in 2 - 6 range'),
+ (LOGGER_NAME, 'INFO', 'Processing users with user ids in 7 - 11 range'),
+ (LOGGER_NAME, 'INFO', 'Processing users with user ids in 12 - 16 range'),
+ )
diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py
index c2fd4564e4..7bbd0cf924 100644
--- a/common/djangoapps/student/roles.py
+++ b/common/djangoapps/student/roles.py
@@ -7,6 +7,7 @@ adding users, removing users, and listing members
import logging
from abc import ABCMeta, abstractmethod
from collections import defaultdict
+from contextlib import contextmanager
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from opaque_keys.edx.django.models import CourseKeyField
@@ -44,6 +45,21 @@ def register_access_role(cls):
return cls
+@contextmanager
+def strict_role_checking():
+ """
+ Context manager that temporarily disables role inheritance.
+
+ You may want to use it to check if a user has a base role. For example, if a user has `CourseLimitedStaffRole`,
+ by enclosing `has_role` call with this context manager, you can check it has the `CourseStaffRole` too. This is
+ useful when derived roles have less permissions than their base roles, but users can have both roles at the same.
+ """
+ OLD_ACCESS_ROLES_INHERITANCE = ACCESS_ROLES_INHERITANCE.copy()
+ ACCESS_ROLES_INHERITANCE.clear()
+ yield
+ ACCESS_ROLES_INHERITANCE.update(OLD_ACCESS_ROLES_INHERITANCE)
+
+
class BulkRoleCache: # lint-amnesty, pylint: disable=missing-class-docstring
CACHE_NAMESPACE = "student.roles.BulkRoleCache"
CACHE_KEY = 'roles_by_user'
@@ -78,7 +94,7 @@ class RoleCache:
)
@staticmethod
- def _get_roles(role):
+ def get_roles(role):
"""
Return the roles that should have the same permissions as the specified role.
"""
@@ -90,7 +106,7 @@ class RoleCache:
or a role that inherits from the specified role, course_id and org.
"""
return any(
- access_role.role in self._get_roles(role) and
+ access_role.role in self.get_roles(role) and
access_role.course_id == course_id and
access_role.org == org
for access_role in self._roles
@@ -463,11 +479,11 @@ class UserBasedRole:
def courses_with_role(self):
"""
- Return a django QuerySet for all of the courses with this user x role. You can access
+ Return a django QuerySet for all of the courses with this user x (or derived from x) role. You can access
any of these properties on each result record:
* user (will be self.user--thus uninteresting)
* org
* course_id
* role (will be self.role--thus uninteresting)
"""
- return CourseAccessRole.objects.filter(role=self.role, user=self.user)
+ return CourseAccessRole.objects.filter(role__in=RoleCache.get_roles(self.role), user=self.user)
diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py
index 7080824125..27e245288d 100644
--- a/common/djangoapps/student/tests/factories.py
+++ b/common/djangoapps/student/tests/factories.py
@@ -35,7 +35,7 @@ from common.djangoapps.student.roles import OrgStaffRole
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
-TEST_PASSWORD = 'test'
+TEST_PASSWORD = 'Password1234'
class GroupFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring
@@ -81,7 +81,7 @@ class UserFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-
model = User
django_get_or_create = ('email', 'username')
- _DEFAULT_PASSWORD = 'test'
+ _DEFAULT_PASSWORD = 'Password1234'
username = factory.Sequence('robot{}'.format)
email = factory.Sequence('robot+test+{}@edx.org'.format)
diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py
index 5c6350c2f0..2914bcd61c 100644
--- a/common/djangoapps/student/tests/test_admin_views.py
+++ b/common/djangoapps/student/tests/test_admin_views.py
@@ -53,7 +53,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase):
'email': self.user.email
}
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# # adding new role from django admin page
response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data)
@@ -78,7 +78,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase):
'course_id': str(self.course.id)
}
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# # adding new role from django admin page
response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data)
@@ -96,7 +96,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase):
}
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# # adding new role from django admin page
response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data)
@@ -115,7 +115,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase):
}
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# # adding new role from django admin page
response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data)
@@ -136,7 +136,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase):
'email': email
}
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Adding new role with invalid data
response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data)
@@ -163,7 +163,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase):
'email': self.user.email
}
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# # adding new role from django admin page
response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data)
@@ -230,7 +230,7 @@ class CourseEnrollmentAdminTest(SharedModuleStoreTestCase):
user=self.user,
course_id=self.course.id, # pylint: disable=no-member
)
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
@ddt.data(*ADMIN_URLS)
@ddt.unpack
@@ -324,13 +324,14 @@ class LoginFailuresAdminTest(TestCase):
def setUpClass(cls):
"""Setup class"""
super().setUpClass()
- cls.user = UserFactory.create(username='§', is_staff=True, is_superuser=True)
+ cls.TEST_PASSWORD = 'Password1234'
+ cls.user = UserFactory.create(username='§', password=cls.TEST_PASSWORD, is_staff=True, is_superuser=True)
cls.user.save()
def setUp(self):
"""Setup."""
super().setUp()
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.user2 = UserFactory.create(username='Zażółć gęślą jaźń')
self.user_lockout_until = datetime.datetime.now(UTC)
LoginFailures.objects.create(user=self.user, failure_count=10, lockout_until=self.user_lockout_until)
diff --git a/common/djangoapps/student/tests/test_authz.py b/common/djangoapps/student/tests/test_authz.py
index a7a3694d34..1c79780e88 100644
--- a/common/djangoapps/student/tests/test_authz.py
+++ b/common/djangoapps/student/tests/test_authz.py
@@ -285,14 +285,14 @@ class CourseGroupTest(TestCase):
with pytest.raises(PermissionDenied):
remove_users(self.staff, CourseStaffRole(self.course_key), another_staff)
- def test_no_limited_staff_read_or_write_access(self):
+ def test_limited_staff_no_studio_read_access(self):
"""
- Test that course limited staff have no read or write access.
+ Verifies that course limited staff have no read, but have write access.
"""
add_users(self.global_admin, CourseLimitedStaffRole(self.course_key), self.limited_staff)
assert not has_studio_read_access(self.limited_staff, self.course_key)
- assert not has_studio_write_access(self.limited_staff, self.course_key)
+ assert has_studio_write_access(self.limited_staff, self.course_key)
class CourseOrgGroupTest(TestCase):
diff --git a/common/djangoapps/student/tests/test_bulk_email_settings.py b/common/djangoapps/student/tests/test_bulk_email_settings.py
index d9bfd15621..308ccf66e0 100644
--- a/common/djangoapps/student/tests/test_bulk_email_settings.py
+++ b/common/djangoapps/student/tests/test_bulk_email_settings.py
@@ -33,7 +33,7 @@ class TestStudentDashboardEmailView(SharedModuleStoreTestCase):
# Create student account
student = UserFactory.create()
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
- self.client.login(username=student.username, password="test")
+ self.client.login(username=student.username, password=self.TEST_PASSWORD)
self.url = reverse('dashboard')
# URL for email settings modal
diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py
index 7f1287fba0..bcede18213 100644
--- a/common/djangoapps/student/tests/test_email.py
+++ b/common/djangoapps/student/tests/test_email.py
@@ -136,7 +136,7 @@ class ActivationEmailTests(EmailTemplateTagMixin, CacheIsolationTestCase):
params = {
'username': 'test_user',
'email': 'test_user@example.com',
- 'password': 'edx',
+ 'password': 'Password1234',
'name': 'Test User',
'honor_code': True,
'terms_of_service': True
@@ -319,7 +319,7 @@ class EmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, CacheIsolat
self.new_email = 'new.email@edx.org'
self.req_factory = RequestFactory()
self.request = self.req_factory.post('unused_url', data={
- 'password': 'test',
+ 'password': 'Password1234',
'new_email': self.new_email
})
self.request.user = self.user
@@ -628,7 +628,7 @@ class SecondaryEmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, Ca
self.new_secondary_email = 'new.secondary.email@edx.org'
self.req_factory = RequestFactory()
self.request = self.req_factory.post('unused_url', data={
- 'password': 'test',
+ 'password': 'Password1234',
'new_email': self.new_secondary_email
})
self.request.user = self.user
diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py
index 6e429fd39a..376595a850 100644
--- a/common/djangoapps/student/tests/test_filters.py
+++ b/common/djangoapps/student/tests/test_filters.py
@@ -316,7 +316,7 @@ class StudentDashboardFiltersTest(ModuleStoreTestCase):
def setUp(self): # pylint: disable=arguments-differ
super().setUp()
self.user = UserFactory()
- self.client.login(username=self.user.username, password="test")
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.dashboard_url = reverse("dashboard")
self.first_course = CourseFactory.create(
org="test1", course="course1", display_name="run1",
diff --git a/common/djangoapps/student/tests/test_retirement.py b/common/djangoapps/student/tests/test_retirement.py
index a7e5fac865..ffeb1af14f 100644
--- a/common/djangoapps/student/tests/test_retirement.py
+++ b/common/djangoapps/student/tests/test_retirement.py
@@ -260,7 +260,7 @@ class TestRegisterRetiredUsername(TestCase):
'username': 'username',
'email': 'foo_bar' + '@bar.com',
'name': 'foo bar',
- 'password': '123',
+ 'password': 'Password1234',
'terms_of_service': 'true',
'honor_code': 'true',
}
diff --git a/common/djangoapps/student/tests/test_userstanding.py b/common/djangoapps/student/tests/test_userstanding.py
index 832127e474..67fcebda28 100644
--- a/common/djangoapps/student/tests/test_userstanding.py
+++ b/common/djangoapps/student/tests/test_userstanding.py
@@ -43,7 +43,7 @@ class UserStandingTest(TestCase):
(self.non_staff, self.non_staff_client),
(self.admin, self.admin_client),
]:
- client.login(username=user.username, password='test')
+ client.login(username=user.username, password='Password1234')
UserStandingFactory.create(
user=self.bad_user,
diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py
index b9518efa04..1230f8320a 100644
--- a/common/djangoapps/student/tests/test_views.py
+++ b/common/djangoapps/student/tests/test_views.py
@@ -49,7 +49,6 @@ from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: d
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
-PASSWORD = 'test'
TOMORROW = now() + timedelta(days=1)
ONE_WEEK_AGO = now() - timedelta(weeks=1)
THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3))
@@ -81,7 +80,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase):
self.user = UserFactory()
self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user)
self.cert_status = 'processing'
- self.client.login(username=self.user.username, password=PASSWORD)
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
def mock_cert(self, _user, _course_overview):
""" Return a preset certificate status. """
@@ -212,7 +211,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin,
"""
super().setUp()
self.user = UserFactory()
- self.client.login(username=self.user.username, password=PASSWORD)
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.path = reverse('dashboard')
def set_course_sharing_urls(self, set_marketing, set_social_sharing):
@@ -1018,7 +1017,7 @@ class TestCourseDashboardNoticesRedirects(SharedModuleStoreTestCase):
def setUp(self):
super().setUp()
self.user = UserFactory()
- self.client.login(username=self.user.username, password=PASSWORD)
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.path = reverse('dashboard')
def test_check_for_unacknowledged_notices(self):
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index b41ad2f856..a90b2504d1 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -278,7 +278,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
- self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test')
+ self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password=self.TEST_PASSWORD)
self.client = Client()
cache.clear()
@@ -307,7 +307,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
"""
Test that the certificate verification status for courses is visible on the dashboard.
"""
- self.client.login(username="jack", password="test")
+ self.client.login(username="jack", password=self.TEST_PASSWORD)
self._check_verification_status_on('verified', 'You're enrolled as a verified student')
self._check_verification_status_on('honor', 'You're enrolled as an honor code student')
self._check_verification_status_off('audit', '')
@@ -345,7 +345,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
Test that the certificate verification status for courses is not visible on the dashboard
if the verified certificates setting is off.
"""
- self.client.login(username="jack", password="test")
+ self.client.login(username="jack", password=self.TEST_PASSWORD)
self._check_verification_status_off('verified', 'You\'re enrolled as a verified student')
self._check_verification_status_off('honor', 'You\'re enrolled as an honor code student')
self._check_verification_status_off('audit', '')
@@ -371,7 +371,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
@skip_unless_lms
def test_linked_in_add_to_profile_btn_not_appearing_without_config(self):
# Without linked-in config don't show Add Certificate to LinkedIn button
- self.client.login(username="jack", password="test")
+ self.client.login(username="jack", password=self.TEST_PASSWORD)
CourseModeFactory.create(
course_id=self.course.id,
@@ -409,7 +409,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
def test_linked_in_add_to_profile_btn_with_certificate(self):
# If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button
# should be visible. and it has URL value with valid parameters.
- self.client.login(username="jack", password="test")
+ self.client.login(username="jack", password=self.TEST_PASSWORD)
linkedin_config = LinkedInAddToProfileConfiguration.objects.create(company_identifier='1337', enabled=True)
CourseModeFactory.create(
@@ -480,7 +480,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
# Create a course and log in the user.
# Creating a new course will trigger a publish event and the course will be cached
test_course = CourseFactory.create(emit_signals=True)
- self.client.login(username="jack", password="test")
+ self.client.login(username="jack", password=self.TEST_PASSWORD)
with check_mongo_calls(0):
CourseEnrollment.enroll(self.user, test_course.id)
@@ -495,7 +495,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
@skip_unless_lms
def test_dashboard_header_nav_has_find_courses(self):
- self.client.login(username="jack", password="test")
+ self.client.login(username="jack", password=self.TEST_PASSWORD)
response = self.client.get(reverse("dashboard"))
# "Explore courses" is shown in the side panel
@@ -542,7 +542,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase):
super().setUp()
self.org = 'fakeX'
self.course = CourseFactory.create(org=self.org)
- self.user = UserFactory.create(username='jack', email='jack@fake.edx.org', password='test')
+ self.user = UserFactory.create(username='jack', email='jack@fake.edx.org', password=self.TEST_PASSWORD)
CourseModeFactory.create(mode_slug='no-id-professional', course_id=self.course.id)
CourseEnrollment.enroll(self.user, self.course.location.course_key, mode='no-id-professional')
cache.clear()
@@ -564,7 +564,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase):
'course_org_filter': self.org
})
self.set_up_site(site_domain, site_configuration_values)
- self.client.login(username='jack', password='test')
+ self.client.login(username='jack', password=self.TEST_PASSWORD)
response = self.client.get(reverse('dashboard'))
self.assertContains(response, 'class="course professional"')
@@ -585,7 +585,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase):
'course_org_filter': self.org
})
self.set_up_site(site_domain, site_configuration_values)
- self.client.login(username='jack', password='test')
+ self.client.login(username='jack', password=self.TEST_PASSWORD)
response = self.client.get(reverse('dashboard'))
self.assertNotContains(response, 'class="course professional"')
@@ -899,8 +899,8 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase):
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
- self.user = UserFactory.create(password='secret')
- self.client.login(username=self.user.username, password='secret')
+ self.user = UserFactory.create(password=self.TEST_PASSWORD)
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.url = reverse('change_enrollment')
def _enroll_through_view(self, course):
@@ -1056,14 +1056,13 @@ class AnonymousLookupTable(ModuleStoreTestCase):
class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Tests verifying that related programs appear on the course dashboard."""
maxDiff = None
- password = 'test'
related_programs_preface = 'Related Programs'
@classmethod
def setUpClass(cls):
super().setUpClass()
- cls.user = UserFactory()
+ cls.user = UserFactory(password=cls.TEST_PASSWORD)
cls.course = CourseFactory()
cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member
@@ -1073,7 +1072,7 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
self.url = reverse('dashboard')
self.create_programs_config()
- self.client.login(username=self.user.username, password=self.password)
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
course_run = CourseRunFactory(key=str(self.course.id)) # pylint: disable=no-member
course = CatalogCourseFactory(course_runs=[course_run])
diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py
index af5159764c..1ea265e868 100644
--- a/common/djangoapps/third_party_auth/models.py
+++ b/common/djangoapps/third_party_auth/models.py
@@ -366,13 +366,12 @@ class OAuth2ProviderConfig(ProviderConfig):
.. no_pii:
"""
- # We are keying the provider config by backend_name here as suggested in the python social
- # auth documentation. In order to reuse a backend for a second provider, a subclass can be
- # created with seperate name.
+ # We are keying the provider config by backend_name and site_id to support configuration per site.
+ # In order to reuse a backend for a second provider, a subclass can be created with seperate name.
# example:
# class SecondOpenIDProvider(OpenIDAuth):
# name = "second-openId-provider"
- KEY_FIELDS = ('backend_name',)
+ KEY_FIELDS = ('site_id', 'backend_name')
prefix = 'oa2'
backend_name = models.CharField(
max_length=50, blank=False, db_index=True,
@@ -401,6 +400,29 @@ class OAuth2ProviderConfig(ProviderConfig):
verbose_name = "Provider Configuration (OAuth)"
verbose_name_plural = verbose_name
+ @classmethod
+ def current(cls, *args):
+ """
+ Get the current config model for the provider according to the given backend and the current
+ site.
+ """
+ site_id = Site.objects.get_current(get_current_request()).id
+ return super(OAuth2ProviderConfig, cls).current(site_id, *args)
+
+ @property
+ def provider_id(self):
+ """
+ Unique string key identifying this provider. Must be URL and css class friendly.
+ Ignoring site_id as the config is filtered using current method which fetches the configuration for the current
+ site_id.
+ """
+ assert self.prefix is not None
+ return "-".join((self.prefix, ) + tuple(
+ str(getattr(self, field))
+ for field in self.KEY_FIELDS
+ if field != 'site_id'
+ ))
+
def clean(self):
""" Standardize and validate fields """
super().clean()
diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py
index 9135ea556b..fde2bb9cbc 100644
--- a/common/djangoapps/third_party_auth/pipeline.py
+++ b/common/djangoapps/third_party_auth/pipeline.py
@@ -854,7 +854,7 @@ def user_details_force_sync(auth_entry, strategy, details, user=None, *args, **k
This step is controlled by the `sync_learner_profile_data` flag on the provider's configuration.
"""
current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs})
- if user and current_provider.sync_learner_profile_data:
+ if user and current_provider and current_provider.sync_learner_profile_data:
# Keep track of which incoming values get applied.
changed = {}
@@ -931,7 +931,7 @@ def set_id_verification_status(auth_entry, strategy, details, user=None, *args,
Use the user's authentication with the provider, if configured, as evidence of their identity being verified.
"""
current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs})
- if user and current_provider.enable_sso_id_verification:
+ if user and current_provider and current_provider.enable_sso_id_verification:
# Get previous valid, non expired verification attempts for this SSO Provider and user
verifications = SSOVerification.objects.filter(
user=user,
diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py
index 116deacf5e..8f96235017 100644
--- a/common/djangoapps/third_party_auth/tests/specs/base.py
+++ b/common/djangoapps/third_party_auth/tests/specs/base.py
@@ -481,7 +481,7 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin):
# The AJAX on the page will log them in:
ajax_login_response = self.client.post(
reverse('user_api_login_session', kwargs={'api_version': 'v1'}),
- {'email': self.user.email, 'password': 'test'}
+ {'email': self.user.email, 'password': 'Password1234'}
)
assert ajax_login_response.status_code == 200
# Then the AJAX will finish the third party auth:
diff --git a/common/djangoapps/third_party_auth/tests/test_admin.py b/common/djangoapps/third_party_auth/tests/test_admin.py
index c5481a3a09..0acfb3492a 100644
--- a/common/djangoapps/third_party_auth/tests/test_admin.py
+++ b/common/djangoapps/third_party_auth/tests/test_admin.py
@@ -14,6 +14,9 @@ from common.djangoapps.third_party_auth.tests import testutil
from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth
+TEST_PASSWORD = 'Password1234'
+
+
# This is necessary because cms does not implement third party auth
@skip_unless_thirdpartyauth()
class Oauth2ProviderConfigAdminTest(testutil.TestCase):
@@ -36,9 +39,9 @@ class Oauth2ProviderConfigAdminTest(testutil.TestCase):
prepopulated correctly, and that we can clear and update the image.
"""
# Login as a super user
- user = UserFactory.create(is_staff=True, is_superuser=True)
+ user = UserFactory.create(is_staff=True, is_superuser=True, password=TEST_PASSWORD)
user.save()
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=TEST_PASSWORD)
# Get baseline provider count
providers = OAuth2ProviderConfig.objects.all()
diff --git a/common/djangoapps/third_party_auth/tests/test_provider.py b/common/djangoapps/third_party_auth/tests/test_provider.py
index 3fa8f80f4d..28e95c16b8 100644
--- a/common/djangoapps/third_party_auth/tests/test_provider.py
+++ b/common/djangoapps/third_party_auth/tests/test_provider.py
@@ -11,7 +11,9 @@ from django.test.utils import CaptureQueriesContext
from common.djangoapps.third_party_auth import provider
from common.djangoapps.third_party_auth.tests import testutil
from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth
-from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
+from openedx.core.djangoapps.site_configuration.tests.test_util import (
+ with_site_configuration, with_site_configuration_context
+)
SITE_DOMAIN_A = 'professionalx.example.com'
SITE_DOMAIN_B = 'somethingelse.example.com'
@@ -114,13 +116,13 @@ class RegistryTest(testutil.TestCase):
assert no_log_in_provider.provider_id not in provider_ids
assert normal_provider.provider_id in provider_ids
- def test_tpa_hint_provider_displayed_for_login(self):
+ def test_tpa_hint_exp_hidden_provider_displayed_for_login(self):
"""
- Tests to ensure that an enabled-but-not-visible provider is presented
+ Test to ensure that an explicitly enabled-but-not-visible provider is presented
for use in the UI when the "tpa_hint" parameter is specified
+ A hidden provider should be accessible with tpa_hint (this is the main case)
"""
- # A hidden provider should be accessible with tpa_hint (this is the main case)
hidden_provider = self.configure_google_provider(visible=False, enabled=True)
provider_ids = [
idp.provider_id
@@ -128,8 +130,14 @@ class RegistryTest(testutil.TestCase):
]
assert hidden_provider.provider_id in provider_ids
- # New providers are hidden (ie, not flagged as 'visible') by default
- # The tpa_hint parameter should work for these providers as well
+ def test_tpa_hint_hidden_provider_displayed_for_login(self):
+ """
+ Tests to ensure that an implicitly enabled-but-not-visible provider is presented
+ for use in the UI when the "tpa_hint" parameter is specified.
+ New providers are hidden (ie, not flagged as 'visible') by default
+ The tpa_hint parameter should work for these providers as well.
+ """
+
implicitly_hidden_provider = self.configure_linkedin_provider(enabled=True)
provider_ids = [
idp.provider_id
@@ -137,7 +145,10 @@ class RegistryTest(testutil.TestCase):
]
assert implicitly_hidden_provider.provider_id in provider_ids
- # Disabled providers should not be matched in tpa_hint scenarios
+ def test_tpa_hint_disabled_hidden_provider_displayed_for_login(self):
+ """
+ Disabled providers should not be matched in tpa_hint scenarios
+ """
disabled_provider = self.configure_twitter_provider(visible=True, enabled=False)
provider_ids = [
idp.provider_id
@@ -145,7 +156,10 @@ class RegistryTest(testutil.TestCase):
]
assert disabled_provider.provider_id not in provider_ids
- # Providers not utilized for learner authentication should not match tpa_hint
+ def test_tpa_hint_no_log_hidden_provider_displayed_for_login(self):
+ """
+ Providers not utilized for learner authentication should not match tpa_hint
+ """
no_log_in_provider = self.configure_lti_provider()
provider_ids = [
idp.provider_id
@@ -153,6 +167,30 @@ class RegistryTest(testutil.TestCase):
]
assert no_log_in_provider.provider_id not in provider_ids
+ def test_get_current_site_oauth_provider(self):
+ """
+ Verify that correct provider for current site is returned even if same backend is used for multiple sites.
+ """
+ site_a = Site.objects.get_or_create(domain=SITE_DOMAIN_A, name=SITE_DOMAIN_A)[0]
+ site_b = Site.objects.get_or_create(domain=SITE_DOMAIN_B, name=SITE_DOMAIN_B)[0]
+ site_a_provider = self.configure_google_provider(visible=True, enabled=True, site=site_a)
+ site_b_provider = self.configure_google_provider(visible=True, enabled=True, site=site_b)
+ with with_site_configuration_context(domain=SITE_DOMAIN_A):
+ assert site_a_provider.enabled_for_current_site is True
+
+ # Registry.displayed_for_login gets providers enabled for current site
+ provider_ids = provider.Registry.displayed_for_login()
+ # Google oauth provider for current site should be displayed
+ assert site_a_provider in provider_ids
+ assert site_b_provider not in provider_ids
+
+ # Similarly, the other site should only see its own providers
+ with with_site_configuration_context(domain=SITE_DOMAIN_B):
+ assert site_b_provider.enabled_for_current_site is True
+ provider_ids = provider.Registry.displayed_for_login()
+ assert site_b_provider in provider_ids
+ assert site_a_provider not in provider_ids
+
def test_provider_enabled_for_current_site(self):
"""
Verify that enabled_for_current_site returns True when the provider matches the current site.
@@ -201,7 +239,7 @@ class RegistryTest(testutil.TestCase):
def test_get_returns_none_if_provider_id_is_none(self):
assert provider.Registry.get(None) is None
- def test_get_returns_none_if_provider_not_enabled(self):
+ def test_get_returns_none_if_provider_not_enabled_change(self):
linkedin_provider_id = "oa2-linkedin-oauth2"
# At this point there should be no configuration entries at all so no providers should be enabled
assert provider.Registry.enabled() == []
@@ -209,6 +247,12 @@ class RegistryTest(testutil.TestCase):
# Now explicitly disabled this provider:
self.configure_linkedin_provider(enabled=False)
assert provider.Registry.get(linkedin_provider_id) is None
+
+ def test_get_returns_provider_if_provider_enabled(self):
+ """
+ Test to ensure that Registry gets enabled providers.
+ """
+ linkedin_provider_id = "oa2-linkedin-oauth2"
self.configure_linkedin_provider(enabled=True)
assert provider.Registry.get(linkedin_provider_id).provider_id == linkedin_provider_id
diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py
index c6a406c530..d24ba8cfd7 100644
--- a/common/djangoapps/third_party_auth/views.py
+++ b/common/djangoapps/third_party_auth/views.py
@@ -47,7 +47,7 @@ def inactive_user_view(request):
if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request)
third_party_provider = provider.Registry.get_from_pipeline(running_pipeline)
- if third_party_provider.skip_email_verification and not activated:
+ if third_party_provider and third_party_provider.skip_email_verification and not activated:
user.is_active = True
user.save()
activated = True
diff --git a/common/djangoapps/util/tests/test_db.py b/common/djangoapps/util/tests/test_db.py
index 4a16c2a20a..ee7825e876 100644
--- a/common/djangoapps/util/tests/test_db.py
+++ b/common/djangoapps/util/tests/test_db.py
@@ -3,6 +3,7 @@
from io import StringIO
import ddt
+import unittest
from django.core.management import call_command
from django.db.transaction import TransactionManagementError, atomic
from django.test import TestCase, TransactionTestCase
@@ -120,6 +121,7 @@ class MigrationTests(TestCase):
Tests for migrations.
"""
+ @unittest.skip("Migration will delete several models. Need to ship not referencing it first")
@override_settings(MIGRATION_MODULES={})
def test_migrations_are_in_sync(self):
"""
diff --git a/common/djangoapps/util/tests/test_password_policy_validators.py b/common/djangoapps/util/tests/test_password_policy_validators.py
index f0ffe3c2b1..f5eea255bf 100644
--- a/common/djangoapps/util/tests/test_password_policy_validators.py
+++ b/common/djangoapps/util/tests/test_password_policy_validators.py
@@ -45,6 +45,11 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase):
validate_password(password, user)
assert msg in ' '.join(cm.value.messages)
+ @override_settings(AUTH_PASSWORD_VALIDATORS=[
+ create_validator_config(
+ 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4}
+ )
+ ])
def test_unicode_password(self):
""" Tests that validate_password enforces unicode """
unicode_str = '𤭮'
@@ -55,12 +60,17 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase):
assert len(unicode_str) == 1
# Test length check
- self.validation_errors_checker(byte_str, 'This password is too short. It must contain at least 2 characters.')
- self.validation_errors_checker(byte_str + byte_str, None)
+ self.validation_errors_checker(byte_str, 'This password is too short. It must contain at least 4 characters.')
+ self.validation_errors_checker(byte_str * 4, None)
# Test badly encoded password
self.validation_errors_checker(b'\xff\xff', 'Invalid password.')
+ @override_settings(AUTH_PASSWORD_VALIDATORS=[
+ create_validator_config(
+ 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4}
+ )
+ ])
def test_password_unicode_normalization(self):
""" Tests that validate_password normalizes passwords """
# s ̣ ̇ (s with combining dot below and combining dot above)
@@ -70,7 +80,7 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase):
# When we normalize we expect the not_normalized password to fail
# because it should be normalized to '\u1E69' -> ṩ
self.validation_errors_checker(not_normalized_password,
- 'This password is too short. It must contain at least 2 characters.')
+ 'This password is too short. It must contain at least 4 characters.')
@data(
([create_validator_config('common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 2})], # lint-amnesty, pylint: disable=line-too-long
diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb
index 46e3379657..d5a6c3e7b7 100644
Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ
diff --git a/conf/locale/ar/LC_MESSAGES/django.po b/conf/locale/ar/LC_MESSAGES/django.po
index 150f0b8043..eac51e287d 100644
--- a/conf/locale/ar/LC_MESSAGES/django.po
+++ b/conf/locale/ar/LC_MESSAGES/django.po
@@ -259,7 +259,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-10 20:42+0000\n"
+"POT-Creation-Date: 2023-10-01 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: NELC Open edX Translation , 2020\n"
"Language-Team: Arabic (https://app.transifex.com/open-edx/teams/6205/ar/)\n"
@@ -3799,12 +3799,12 @@ msgid "Good"
msgstr "جيّد"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7601,6 +7601,14 @@ msgstr "أردج {country} على اللائحة السوداء للمساق {co
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7637,6 +7645,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/ar/LC_MESSAGES/djangojs.po b/conf/locale/ar/LC_MESSAGES/djangojs.po
index 09d3be4a95..4c3922ce7e 100644
--- a/conf/locale/ar/LC_MESSAGES/djangojs.po
+++ b/conf/locale/ar/LC_MESSAGES/djangojs.po
@@ -194,7 +194,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Abderraouf Mehdi Bouhali , 2022\n"
"Language-Team: Arabic (http://app.transifex.com/open-edx/edx-platform/language/ar/)\n"
diff --git a/conf/locale/ca/LC_MESSAGES/django.po b/conf/locale/ca/LC_MESSAGES/django.po
index 418d0a02ab..409ce7fb74 100644
--- a/conf/locale/ca/LC_MESSAGES/django.po
+++ b/conf/locale/ca/LC_MESSAGES/django.po
@@ -67,7 +67,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Catalan (https://app.transifex.com/open-edx/teams/6205/ca/)\n"
@@ -3250,12 +3250,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6803,6 +6803,14 @@ msgstr ""
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6839,6 +6847,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/ca/LC_MESSAGES/djangojs.po b/conf/locale/ca/LC_MESSAGES/djangojs.po
index 9e6413627b..5e9a5cd774 100644
--- a/conf/locale/ca/LC_MESSAGES/djangojs.po
+++ b/conf/locale/ca/LC_MESSAGES/djangojs.po
@@ -48,7 +48,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Catalan (http://app.transifex.com/open-edx/edx-platform/language/ca/)\n"
diff --git a/conf/locale/de_DE/LC_MESSAGES/django.po b/conf/locale/de_DE/LC_MESSAGES/django.po
index ce94b6980b..f7bc07af7b 100644
--- a/conf/locale/de_DE/LC_MESSAGES/django.po
+++ b/conf/locale/de_DE/LC_MESSAGES/django.po
@@ -177,7 +177,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-10 20:42+0000\n"
+"POT-Creation-Date: 2023-10-01 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Stefania Trabucchi , 2019\n"
"Language-Team: German (Germany) (https://app.transifex.com/open-edx/teams/6205/de_DE/)\n"
@@ -3691,12 +3691,12 @@ msgid "Good"
msgstr "Gut"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7563,6 +7563,14 @@ msgstr "Blacklist {country} für {course}"
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7599,6 +7607,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/de_DE/LC_MESSAGES/djangojs.po b/conf/locale/de_DE/LC_MESSAGES/djangojs.po
index 8dc44711bb..27dc186b42 100644
--- a/conf/locale/de_DE/LC_MESSAGES/djangojs.po
+++ b/conf/locale/de_DE/LC_MESSAGES/djangojs.po
@@ -134,7 +134,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Alfredo Guillem, 2022\n"
"Language-Team: German (Germany) (http://app.transifex.com/open-edx/edx-platform/language/de_DE/)\n"
diff --git a/conf/locale/el/LC_MESSAGES/django.po b/conf/locale/el/LC_MESSAGES/django.po
index f6ce1c8a49..956aeba85e 100644
--- a/conf/locale/el/LC_MESSAGES/django.po
+++ b/conf/locale/el/LC_MESSAGES/django.po
@@ -98,7 +98,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Greek (https://app.transifex.com/open-edx/teams/6205/el/)\n"
@@ -3376,12 +3376,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6950,6 +6950,14 @@ msgstr ""
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6986,6 +6994,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/el/LC_MESSAGES/djangojs.po b/conf/locale/el/LC_MESSAGES/djangojs.po
index 19025b0dd5..2355c9b335 100644
--- a/conf/locale/el/LC_MESSAGES/djangojs.po
+++ b/conf/locale/el/LC_MESSAGES/djangojs.po
@@ -87,7 +87,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Angelos Chraniotis, 2023\n"
"Language-Team: Greek (http://app.transifex.com/open-edx/edx-platform/language/el/)\n"
diff --git a/conf/locale/en/LC_MESSAGES/django.po b/conf/locale/en/LC_MESSAGES/django.po
index 52f45b05ba..4c4bfdcc5e 100644
--- a/conf/locale/en/LC_MESSAGES/django.po
+++ b/conf/locale/en/LC_MESSAGES/django.po
@@ -38,8 +38,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-10-01 20:36+0000\n"
-"PO-Revision-Date: 2023-10-01 20:36:14.857987\n"
+"POT-Creation-Date: 2023-10-22 20:35+0000\n"
+"PO-Revision-Date: 2023-10-22 20:35:52.246505\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: en\n"
@@ -1827,53 +1827,6 @@ msgstr ""
msgid "This value must be all lowercase."
msgstr ""
-#: lms/djangoapps/badges/models.py
-msgid "The course mode for this badge image. For example, \"verified\" or \"honor\"."
-msgstr ""
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Badge images must be square PNG files. The file size should be under 250KB."
-msgstr ""
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Set this value to True if you want this image to be the default image for "
-"any course modes that do not have a specified badge image. You can have only"
-" one default image."
-msgstr ""
-
-#: lms/djangoapps/badges/models.py
-msgid "There can be only one default image."
-msgstr ""
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"On each line, put the number of completed courses to award a badge for, a "
-"comma, and the slug of a badge class you have created that has the issuing "
-"component 'openedx__course'. For example: 3,enrolled_3_courses"
-msgstr ""
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"On each line, put the number of enrolled courses to award a badge for, a "
-"comma, and the slug of a badge class you have created that has the issuing "
-"component 'openedx__course'. For example: 3,enrolled_3_courses"
-msgstr ""
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Each line is a comma-separated list. The first item in each line is the slug"
-" of a badge class you have created that has an issuing component of "
-"'openedx__course'. The remaining items in each line are the course keys the "
-"learner needs to complete to be awarded the badge. For example: "
-"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second"
-msgstr ""
-
-#: lms/djangoapps/badges/models.py
-msgid "Please check the syntax of your entry."
-msgstr ""
-
#. Translators: This string is used across Open edX installations
#. as a callback to edX. Please do not translate `edX.org`
#: lms/djangoapps/branding/api.py
@@ -3235,12 +3188,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6853,6 +6806,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
@@ -9737,16 +9704,6 @@ msgid ""
"thumbnail image on the Settings & Details page."
msgstr ""
-#: xmodule/course_block.py
-msgid "Issue Open Badges"
-msgstr ""
-
-#: xmodule/course_block.py
-msgid ""
-"Issue Open Badges badges for this course. Badges are generated when "
-"certificates are created."
-msgstr ""
-
#: xmodule/course_block.py
msgid ""
"Use this setting only when generating PDF certificates. Between quotation "
@@ -18758,7 +18715,7 @@ msgid "Studio Accessibility Policy"
msgstr ""
#: cms/templates/asset_index.html cms/templates/widgets/header.html
-msgid "Files & Uploads"
+msgid "Files"
msgstr ""
#: cms/templates/certificates.html
@@ -20410,6 +20367,10 @@ msgstr ""
msgid "Libraries"
msgstr ""
+#: cms/templates/index.html
+msgid "Taxonomies"
+msgstr ""
+
#: cms/templates/index.html
msgid "Are you staff on an existing {studio_name} course?"
msgstr ""
diff --git a/conf/locale/en/LC_MESSAGES/djangojs.po b/conf/locale/en/LC_MESSAGES/djangojs.po
index d0b2f6871c..c200d37c47 100644
--- a/conf/locale/en/LC_MESSAGES/djangojs.po
+++ b/conf/locale/en/LC_MESSAGES/djangojs.po
@@ -32,8 +32,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-10-01 20:36+0000\n"
-"PO-Revision-Date: 2023-10-01 20:36:14.819628\n"
+"POT-Creation-Date: 2023-10-22 20:35+0000\n"
+"PO-Revision-Date: 2023-10-22 20:35:52.488124\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: en\n"
@@ -132,7 +132,6 @@ msgstr ""
#: cms/templates/js/course-video-settings.underscore
#: common/static/common/templates/discussion/templates.underscore
#: common/static/common/templates/image-modal.underscore
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
msgid "Close"
msgstr ""
@@ -3679,10 +3678,6 @@ msgid ""
"interests are, why you're taking courses, or what you hope to learn."
msgstr ""
-#: openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js
-msgid "Accomplishments Pagination"
-msgstr ""
-
#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js
msgid "Account Settings page."
msgstr ""
@@ -3711,10 +3706,6 @@ msgstr ""
msgid "About Me"
msgstr ""
-#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js
-msgid "Accomplishments"
-msgstr ""
-
#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js
msgid "Profile"
msgstr ""
@@ -9201,32 +9192,6 @@ msgstr[1] ""
msgid "Sorry, no results were found."
msgstr ""
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-#, python-format
-msgid "Share your \"%(display_name)s\" award"
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-msgid "Share"
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-#, python-format
-msgid "Earned %(created)s."
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "What's Your Next Accomplishment?"
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "Start working toward your next learning goal."
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "Find a course"
-msgstr ""
-
#: openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore
msgid "You are currently sharing a limited profile."
msgstr ""
@@ -9235,30 +9200,6 @@ msgstr ""
msgid "This learner is currently sharing a limited profile."
msgstr ""
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid "Share on Mozilla Backpack"
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"To share your certificate on Mozilla Backpack, you must first have a "
-"Backpack account. Complete the following steps to add your certificate to "
-"Backpack."
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"Create a {link_start}Mozilla Backpack{link_end} account, or log in to your "
-"existing account"
-msgstr ""
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"{download_link_start}Download this image (right-click or option-click, save "
-"as){link_end} and then {upload_link_start}upload{link_end} it to your "
-"backpack."
-msgstr ""
-
#: cms/templates/js/access-editor.underscore
msgid "Limit Access"
msgstr ""
diff --git a/conf/locale/eo/LC_MESSAGES/django.mo b/conf/locale/eo/LC_MESSAGES/django.mo
index bacfc67180..b521617652 100644
Binary files a/conf/locale/eo/LC_MESSAGES/django.mo and b/conf/locale/eo/LC_MESSAGES/django.mo differ
diff --git a/conf/locale/eo/LC_MESSAGES/django.po b/conf/locale/eo/LC_MESSAGES/django.po
index 1892fb2de7..cc8726ae8d 100644
--- a/conf/locale/eo/LC_MESSAGES/django.po
+++ b/conf/locale/eo/LC_MESSAGES/django.po
@@ -38,8 +38,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-10-01 20:36+0000\n"
-"PO-Revision-Date: 2023-10-01 20:36:14.857987\n"
+"POT-Creation-Date: 2023-10-22 20:35+0000\n"
+"PO-Revision-Date: 2023-10-22 20:35:52.246505\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: eo\n"
@@ -2320,90 +2320,6 @@ msgid "This value must be all lowercase."
msgstr ""
"Thïs välüé müst ßé äll löwérçäsé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#"
-#: lms/djangoapps/badges/models.py
-msgid "The course mode for this badge image. For example, \"verified\" or \"honor\"."
-msgstr ""
-"Thé çöürsé mödé för thïs ßädgé ïmägé. För éxämplé, \"vérïfïéd\" ör "
-"\"hönör\". Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#"
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Badge images must be square PNG files. The file size should be under 250KB."
-msgstr ""
-"Bädgé ïmägés müst ßé sqüäré PNG fïlés. Thé fïlé sïzé shöüld ßé ündér 250KB. "
-"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#"
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Set this value to True if you want this image to be the default image for "
-"any course modes that do not have a specified badge image. You can have only"
-" one default image."
-msgstr ""
-"Sét thïs välüé tö Trüé ïf ýöü wänt thïs ïmägé tö ßé thé défäült ïmägé för "
-"äný çöürsé mödés thät dö nöt hävé ä spéçïfïéd ßädgé ïmägé. Ýöü çän hävé önlý"
-" öné défäült ïmägé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg "
-"єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт "
-"єηιм α∂ мιηιм νєηιαм, qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт "
-"αłιqυιρ єχ єα ¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη "
-"νσłυρтαтє νєłιт єѕѕє ¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт "
-"σ¢¢αє¢αт ¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒ#"
-
-#: lms/djangoapps/badges/models.py
-msgid "There can be only one default image."
-msgstr ""
-"Théré çän ßé önlý öné défäült ïmägé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
-"¢σηѕє¢тєтυ#"
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"On each line, put the number of completed courses to award a badge for, a "
-"comma, and the slug of a badge class you have created that has the issuing "
-"component 'openedx__course'. For example: 3,enrolled_3_courses"
-msgstr ""
-"Ön éäçh lïné, püt thé nümßér öf çömplétéd çöürsés tö äwärd ä ßädgé för, ä "
-"çömmä, änd thé slüg öf ä ßädgé çläss ýöü hävé çréätéd thät häs thé ïssüïng "
-"çömpönént 'öpénédx__çöürsé'. För éxämplé: 3,énrölléd_3_çöürsés Ⱡ'σяєм ιρѕυм "
-"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя "
-"ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм, qυιѕ "
-"ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ "
-"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє "
-"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρт#"
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"On each line, put the number of enrolled courses to award a badge for, a "
-"comma, and the slug of a badge class you have created that has the issuing "
-"component 'openedx__course'. For example: 3,enrolled_3_courses"
-msgstr ""
-"Ön éäçh lïné, püt thé nümßér öf énrölléd çöürsés tö äwärd ä ßädgé för, ä "
-"çömmä, änd thé slüg öf ä ßädgé çläss ýöü hävé çréätéd thät häs thé ïssüïng "
-"çömpönént 'öpénédx__çöürsé'. För éxämplé: 3,énrölléd_3_çöürsés Ⱡ'σяєм ιρѕυм "
-"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя "
-"ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм, qυιѕ "
-"ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ "
-"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє "
-"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтє#"
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Each line is a comma-separated list. The first item in each line is the slug"
-" of a badge class you have created that has an issuing component of "
-"'openedx__course'. The remaining items in each line are the course keys the "
-"learner needs to complete to be awarded the badge. For example: "
-"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second"
-msgstr ""
-"Éäçh lïné ïs ä çömmä-sépärätéd lïst. Thé fïrst ïtém ïn éäçh lïné ïs thé slüg"
-" öf ä ßädgé çläss ýöü hävé çréätéd thät häs än ïssüïng çömpönént öf "
-"'öpénédx__çöürsé'. Thé rémäïnïng ïtéms ïn éäçh lïné äré thé çöürsé kéýs thé "
-"léärnér nééds tö çömplété tö ßé äwärdéd thé ßädgé. För éxämplé: "
-"slüg_för_çömpsçï_çöürsés_gröüp_ßädgé,çöürsé-v1:ÇömpSçï+Çöürsé+Fïrst,çöürsé-v1:ÇömpsSçï+Çöürsé+Séçönd#"
-
-#: lms/djangoapps/badges/models.py
-msgid "Please check the syntax of your entry."
-msgstr ""
-"Pléäsé çhéçk thé sýntäx öf ýöür éntrý. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
-"¢σηѕє¢тєтυя#"
-
#. Translators: This string is used across Open edX installations
#. as a callback to edX. Please do not translate `edX.org`
#: lms/djangoapps/branding/api.py
@@ -4122,12 +4038,12 @@ msgid "Good"
msgstr "Gööd Ⱡ'σяєм ι#"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr "ýöür Ⱡ'σяєм ι#"
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr "théïr Ⱡ'σяєм ιρѕ#"
@@ -8713,6 +8629,26 @@ msgstr ""
"<{strong}>{post_title}{strong}>{p}> Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
"¢σηѕє¢т#"
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+"<{p}><{strong}>{replier_name}{strong}> réspöndéd tö ä pöst ýöü’ré "
+"föllöwïng: <{strong}>{post_title}{strong}>{p}> Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт "
+"αмєт, ¢σηѕє¢тєтυя α#"
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+"<{p}><{strong}>{replier_name}{strong}> çömméntéd ön {author_name}'s "
+"réspönsé ïn ä pöst ýöü’ré föllöwïng <{strong}>{post_title}{strong}>{p}> "
+"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#"
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
@@ -12444,18 +12380,6 @@ msgstr ""
"Édït thé nämé öf thé vïdéö thümßnäïl ïmägé fïlé. Ýöü çän sét thé vïdéö "
"thümßnäïl ïmägé ön thé Séttïngs & Détäïls pägé. Ⱡ'σяєм ιρѕυм #"
-#: xmodule/course_block.py
-msgid "Issue Open Badges"
-msgstr "Ìssüé Öpén Bädgés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмє#"
-
-#: xmodule/course_block.py
-msgid ""
-"Issue Open Badges badges for this course. Badges are generated when "
-"certificates are created."
-msgstr ""
-"Ìssüé Öpén Bädgés ßädgés för thïs çöürsé. Bädgés äré générätéd whén "
-"çértïfïçätés äré çréätéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#"
-
#: xmodule/course_block.py
msgid ""
"Use this setting only when generating PDF certificates. Between quotation "
@@ -24182,8 +24106,8 @@ msgid "Studio Accessibility Policy"
msgstr "Stüdïö Àççéssïßïlïtý Pölïçý Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#"
#: cms/templates/asset_index.html cms/templates/widgets/header.html
-msgid "Files & Uploads"
-msgstr "Fïlés & Ûplöäds Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
+msgid "Files"
+msgstr "Fïlés Ⱡ'σяєм ιρѕ#"
#: cms/templates/certificates.html
msgid "Course Certificates"
@@ -26496,6 +26420,10 @@ msgstr "Àrçhïvéd Çöürsés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αм
msgid "Libraries"
msgstr "Lïßrärïés Ⱡ'σяєм ιρѕυм ∂σł#"
+#: cms/templates/index.html
+msgid "Taxonomies"
+msgstr "Täxönömïés Ⱡ'σяєм ιρѕυм ∂σłσ#"
+
#: cms/templates/index.html
msgid "Are you staff on an existing {studio_name} course?"
msgstr ""
diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.mo b/conf/locale/eo/LC_MESSAGES/djangojs.mo
index a0ae05bf43..04a57cb98c 100644
Binary files a/conf/locale/eo/LC_MESSAGES/djangojs.mo and b/conf/locale/eo/LC_MESSAGES/djangojs.mo differ
diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.po b/conf/locale/eo/LC_MESSAGES/djangojs.po
index 31d5461819..b5201799f4 100644
--- a/conf/locale/eo/LC_MESSAGES/djangojs.po
+++ b/conf/locale/eo/LC_MESSAGES/djangojs.po
@@ -32,8 +32,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-10-01 20:36+0000\n"
-"PO-Revision-Date: 2023-10-01 20:36:14.819628\n"
+"POT-Creation-Date: 2023-10-22 20:35+0000\n"
+"PO-Revision-Date: 2023-10-22 20:35:52.488124\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: eo\n"
@@ -132,7 +132,6 @@ msgstr "Ûplöädïng Ⱡ'σяєм ιρѕυм ∂σł#"
#: cms/templates/js/course-video-settings.underscore
#: common/static/common/templates/discussion/templates.underscore
#: common/static/common/templates/image-modal.underscore
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
msgid "Close"
msgstr "Çlösé Ⱡ'σяєм ιρѕ#"
@@ -4634,10 +4633,6 @@ msgstr ""
"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт ¢υρι∂αтαт "
"ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм ι∂ єѕт łα#"
-#: openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js
-msgid "Accomplishments Pagination"
-msgstr "Àççömplïshménts Pägïnätïön Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
-
#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js
msgid "Account Settings page."
msgstr "Àççöünt Séttïngs pägé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#"
@@ -4672,10 +4667,6 @@ msgstr "Pröfïlé ïmägé för {username} Ⱡ'σяєм ιρѕυм ∂σłσя
msgid "About Me"
msgstr "Àßöüt Mé Ⱡ'σяєм ιρѕυм ∂#"
-#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js
-msgid "Accomplishments"
-msgstr "Àççömplïshménts Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#"
-
#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js
msgid "Profile"
msgstr "Pröfïlé Ⱡ'σяєм ιρѕυм #"
@@ -11066,35 +11057,6 @@ msgstr[1] "Löäd néxt {num_items} résülts Ⱡ'σяєм ιρѕυм ∂σłσ
msgid "Sorry, no results were found."
msgstr "Sörrý, nö résülts wéré föünd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#"
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-#, python-format
-msgid "Share your \"%(display_name)s\" award"
-msgstr "Shäré ýöür \"%(display_name)s\" äwärd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-msgid "Share"
-msgstr "Shäré Ⱡ'σяєм ιρѕ#"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-#, python-format
-msgid "Earned %(created)s."
-msgstr "Éärnéd %(created)s. Ⱡ'σяєм ιρѕυм ∂σłσя #"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "What's Your Next Accomplishment?"
-msgstr ""
-"Whät's Ýöür Néxt Àççömplïshmént? Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "Start working toward your next learning goal."
-msgstr ""
-"Stärt wörkïng töwärd ýöür néxt léärnïng göäl. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
-"¢σηѕє¢тєтυя #"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "Find a course"
-msgstr "Fïnd ä çöürsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#"
-
#: openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore
msgid "You are currently sharing a limited profile."
msgstr ""
@@ -11107,43 +11069,6 @@ msgstr ""
"Thïs léärnér ïs çürréntlý shärïng ä lïmïtéd pröfïlé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт "
"αмєт, ¢σηѕє¢тєтυя α#"
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid "Share on Mozilla Backpack"
-msgstr "Shäré ön Mözïllä Bäçkpäçk Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"To share your certificate on Mozilla Backpack, you must first have a "
-"Backpack account. Complete the following steps to add your certificate to "
-"Backpack."
-msgstr ""
-"Tö shäré ýöür çértïfïçäté ön Mözïllä Bäçkpäçk, ýöü müst fïrst hävé ä "
-"Bäçkpäçk äççöünt. Çömplété thé föllöwïng stéps tö ädd ýöür çértïfïçäté tö "
-"Bäçkpäçk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ "
-"єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм"
-" νєηιαм, qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα "
-"¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт"
-" єѕѕє ¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт "
-"¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт#"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"Create a {link_start}Mozilla Backpack{link_end} account, or log in to your "
-"existing account"
-msgstr ""
-"Çréäté ä {link_start}Mözïllä Bäçkpäçk{link_end} äççöünt, ör lög ïn tö ýöür "
-"éxïstïng äççöünt Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"{download_link_start}Download this image (right-click or option-click, save "
-"as){link_end} and then {upload_link_start}upload{link_end} it to your "
-"backpack."
-msgstr ""
-"{download_link_start}Döwnlöäd thïs ïmägé (rïght-çlïçk ör öptïön-çlïçk, sävé "
-"äs){link_end} änd thén {upload_link_start}üplöäd{link_end} ït tö ýöür "
-"ßäçkpäçk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#"
-
#: cms/templates/js/access-editor.underscore
msgid "Limit Access"
msgstr "Lïmït Àççéss Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#"
diff --git a/conf/locale/es_419/LC_MESSAGES/django.mo b/conf/locale/es_419/LC_MESSAGES/django.mo
index e4fb596ceb..0415da9a70 100644
Binary files a/conf/locale/es_419/LC_MESSAGES/django.mo and b/conf/locale/es_419/LC_MESSAGES/django.mo differ
diff --git a/conf/locale/es_419/LC_MESSAGES/django.po b/conf/locale/es_419/LC_MESSAGES/django.po
index d5b34818ea..8756d24480 100644
--- a/conf/locale/es_419/LC_MESSAGES/django.po
+++ b/conf/locale/es_419/LC_MESSAGES/django.po
@@ -296,7 +296,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-10 20:42+0000\n"
+"POT-Creation-Date: 2023-10-01 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Albeiro Gonzalez , 2019\n"
"Language-Team: Spanish (Latin America) (https://app.transifex.com/open-edx/teams/6205/es_419/)\n"
@@ -3969,12 +3969,12 @@ msgid "Good"
msgstr "Bien"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr "su"
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr "su"
@@ -7970,6 +7970,14 @@ msgstr "Incluir en lista negra a {country} para el curso {course}"
msgid "Learner Pathways"
msgstr "Rutas de aprendizaje"
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr "Aplicación de notificación"
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr "Tipo de notificación"
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -8017,6 +8025,25 @@ msgstr ""
"<{p}><{strong}>{username}{strong}> preguntó "
"<{strong}>{post_title}{strong}>{p}>"
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+"<{p}><{strong}>{replier_name}{strong}> respondió a su publicación "
+"<{strong}>{post_title}{strong}>{p}>"
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+"<{p}><{strong}>{replier_name}{strong}> respondió el "
+"<{strong}>{author_name}{strong}> respuesta a su publicación "
+"<{strong}>{post_title}{strong}>{p}>"
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/es_419/LC_MESSAGES/djangojs.po b/conf/locale/es_419/LC_MESSAGES/djangojs.po
index 81688ff415..7772c64531 100644
--- a/conf/locale/es_419/LC_MESSAGES/djangojs.po
+++ b/conf/locale/es_419/LC_MESSAGES/djangojs.po
@@ -182,7 +182,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Jesica Greco, 2023\n"
"Language-Team: Spanish (Latin America) (http://app.transifex.com/open-edx/edx-platform/language/es_419/)\n"
diff --git a/conf/locale/eu_ES/LC_MESSAGES/django.po b/conf/locale/eu_ES/LC_MESSAGES/django.po
index 3942a14866..3d2c37bcd9 100644
--- a/conf/locale/eu_ES/LC_MESSAGES/django.po
+++ b/conf/locale/eu_ES/LC_MESSAGES/django.po
@@ -64,7 +64,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Abel Camacho , 2019\n"
"Language-Team: Basque (Spain) (https://app.transifex.com/open-edx/teams/6205/eu_ES/)\n"
@@ -3315,12 +3315,12 @@ msgid "Good"
msgstr "Ona"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6917,6 +6917,14 @@ msgstr "{country} zerrenda beltza {course}-rako"
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6953,6 +6961,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/eu_ES/LC_MESSAGES/djangojs.po b/conf/locale/eu_ES/LC_MESSAGES/djangojs.po
index 93b1d94852..c6202f7069 100644
--- a/conf/locale/eu_ES/LC_MESSAGES/djangojs.po
+++ b/conf/locale/eu_ES/LC_MESSAGES/djangojs.po
@@ -50,7 +50,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Abel Camacho , 2017,2019-2020\n"
"Language-Team: Basque (Spain) (http://app.transifex.com/open-edx/edx-platform/language/eu_ES/)\n"
diff --git a/conf/locale/fa_IR/LC_MESSAGES/django.po b/conf/locale/fa_IR/LC_MESSAGES/django.po
index 2458f76795..973512ea8a 100644
--- a/conf/locale/fa_IR/LC_MESSAGES/django.po
+++ b/conf/locale/fa_IR/LC_MESSAGES/django.po
@@ -134,7 +134,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-24 20:43+0000\n"
+"POT-Creation-Date: 2023-10-15 20:42+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Somaye Joolaee, 2022\n"
"Language-Team: Persian (Iran) (https://app.transifex.com/open-edx/teams/6205/fa_IR/)\n"
@@ -3664,12 +3664,12 @@ msgid "Good"
msgstr "خوب"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7548,6 +7548,14 @@ msgstr "فهرست سیاه {country} برای {course}"
msgid "Learner Pathways"
msgstr "مسیرهای یادگیرنده"
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7588,6 +7596,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/fa_IR/LC_MESSAGES/djangojs.po b/conf/locale/fa_IR/LC_MESSAGES/djangojs.po
index 24db4fbf75..39d4a4a06f 100644
--- a/conf/locale/fa_IR/LC_MESSAGES/djangojs.po
+++ b/conf/locale/fa_IR/LC_MESSAGES/djangojs.po
@@ -88,7 +88,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: SeyedMahdi Saeid , 2023\n"
"Language-Team: Persian (Iran) (http://app.transifex.com/open-edx/edx-platform/language/fa_IR/)\n"
diff --git a/conf/locale/fr/LC_MESSAGES/django.po b/conf/locale/fr/LC_MESSAGES/django.po
index 5b339180ad..908197c5c5 100644
--- a/conf/locale/fr/LC_MESSAGES/django.po
+++ b/conf/locale/fr/LC_MESSAGES/django.po
@@ -316,7 +316,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Alexandre DS , 2020\n"
"Language-Team: French (https://app.transifex.com/open-edx/teams/6205/fr/)\n"
@@ -3982,12 +3982,12 @@ msgid "Good"
msgstr "Bien"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7967,6 +7967,14 @@ msgstr "Mettre sur le pays {country} sur liste noire pour {course}"
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -8003,6 +8011,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/fr/LC_MESSAGES/djangojs.po b/conf/locale/fr/LC_MESSAGES/djangojs.po
index ea4134d773..ac60a46e94 100644
--- a/conf/locale/fr/LC_MESSAGES/djangojs.po
+++ b/conf/locale/fr/LC_MESSAGES/djangojs.po
@@ -220,7 +220,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Pierre Mailhot , 2023\n"
"Language-Team: French (http://app.transifex.com/open-edx/edx-platform/language/fr/)\n"
diff --git a/conf/locale/he/LC_MESSAGES/django.po b/conf/locale/he/LC_MESSAGES/django.po
index d29bdaef1f..2ed94f23c8 100644
--- a/conf/locale/he/LC_MESSAGES/django.po
+++ b/conf/locale/he/LC_MESSAGES/django.po
@@ -19121,10 +19121,6 @@ msgid ""
"subjects."
msgstr "עיין בקורסים שהושקו לאחרונה וראה מה חדש בנושאים האהובים עליך."
-#: themes/edx.org/lms/templates/dashboard.html
-msgid "Take advantage of free coaching!"
-msgstr ""
-
#: themes/edx.org/lms/templates/dashboard.html
msgid "Get Started"
msgstr ""
diff --git a/conf/locale/hi/LC_MESSAGES/django.po b/conf/locale/hi/LC_MESSAGES/django.po
index 3ecb5136ab..a7bca1b22a 100644
--- a/conf/locale/hi/LC_MESSAGES/django.po
+++ b/conf/locale/hi/LC_MESSAGES/django.po
@@ -18201,10 +18201,6 @@ msgid ""
"subjects."
msgstr ""
-#: themes/edx.org/lms/templates/dashboard.html
-msgid "Take advantage of free coaching!"
-msgstr ""
-
#: themes/edx.org/lms/templates/dashboard.html
msgid "Get Started"
msgstr ""
diff --git a/conf/locale/id/LC_MESSAGES/django.po b/conf/locale/id/LC_MESSAGES/django.po
index 24719010aa..8dbe2acb83 100644
--- a/conf/locale/id/LC_MESSAGES/django.po
+++ b/conf/locale/id/LC_MESSAGES/django.po
@@ -106,7 +106,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Aprisa Chrysantina , 2019\n"
"Language-Team: Indonesian (https://app.transifex.com/open-edx/teams/6205/id/)\n"
@@ -3463,12 +3463,12 @@ msgid "Good"
msgstr "Baik"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7140,6 +7140,14 @@ msgstr "Daftar hitamkan {country} untuk {course}"
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7176,6 +7184,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/id/LC_MESSAGES/djangojs.po b/conf/locale/id/LC_MESSAGES/djangojs.po
index fab593b619..2da633adeb 100644
--- a/conf/locale/id/LC_MESSAGES/djangojs.po
+++ b/conf/locale/id/LC_MESSAGES/djangojs.po
@@ -84,7 +84,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Faizar Septiawan , 2023\n"
"Language-Team: Indonesian (http://app.transifex.com/open-edx/edx-platform/language/id/)\n"
diff --git a/conf/locale/it_IT/LC_MESSAGES/django.po b/conf/locale/it_IT/LC_MESSAGES/django.po
index ae49f47dda..13faaa463a 100644
--- a/conf/locale/it_IT/LC_MESSAGES/django.po
+++ b/conf/locale/it_IT/LC_MESSAGES/django.po
@@ -127,7 +127,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-10 20:42+0000\n"
+"POT-Creation-Date: 2023-10-01 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Ilaria Botti , 2021\n"
"Language-Team: Italian (Italy) (https://app.transifex.com/open-edx/teams/6205/it_IT/)\n"
@@ -3811,12 +3811,12 @@ msgid "Good"
msgstr "Buono"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7814,6 +7814,14 @@ msgstr "Lista di proscrizione {country} per {course}"
msgid "Learner Pathways"
msgstr "Percorsi di apprendimento"
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7850,6 +7858,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/it_IT/LC_MESSAGES/djangojs.po b/conf/locale/it_IT/LC_MESSAGES/djangojs.po
index fc3081764f..d7b7a8a435 100644
--- a/conf/locale/it_IT/LC_MESSAGES/djangojs.po
+++ b/conf/locale/it_IT/LC_MESSAGES/djangojs.po
@@ -113,7 +113,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Ilaria Botti , 2022\n"
"Language-Team: Italian (Italy) (http://app.transifex.com/open-edx/edx-platform/language/it_IT/)\n"
diff --git a/conf/locale/ja_JP/LC_MESSAGES/django.po b/conf/locale/ja_JP/LC_MESSAGES/django.po
index 9f67617d19..f5dceb99c8 100644
--- a/conf/locale/ja_JP/LC_MESSAGES/django.po
+++ b/conf/locale/ja_JP/LC_MESSAGES/django.po
@@ -114,7 +114,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Japanese (Japan) (https://app.transifex.com/open-edx/teams/6205/ja_JP/)\n"
@@ -3311,12 +3311,12 @@ msgid "Good"
msgstr "良い"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6852,6 +6852,14 @@ msgstr "{course} 講座のブラックリスト {country} "
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6888,6 +6896,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/ja_JP/LC_MESSAGES/djangojs.po b/conf/locale/ja_JP/LC_MESSAGES/djangojs.po
index 8bcde97424..330c7a8099 100644
--- a/conf/locale/ja_JP/LC_MESSAGES/djangojs.po
+++ b/conf/locale/ja_JP/LC_MESSAGES/djangojs.po
@@ -78,7 +78,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Kyoto University , 2017\n"
"Language-Team: Japanese (Japan) (http://app.transifex.com/open-edx/edx-platform/language/ja_JP/)\n"
diff --git a/conf/locale/ka/LC_MESSAGES/django.po b/conf/locale/ka/LC_MESSAGES/django.po
index 64c62625d1..936221fd80 100644
--- a/conf/locale/ka/LC_MESSAGES/django.po
+++ b/conf/locale/ka/LC_MESSAGES/django.po
@@ -60,7 +60,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Georgian (https://app.transifex.com/open-edx/teams/6205/ka/)\n"
@@ -3372,12 +3372,12 @@ msgid "Good"
msgstr "კარგია"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7032,6 +7032,14 @@ msgstr "დაემატოს \"{country}\" {course} კურსის შ
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7068,6 +7076,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/ka/LC_MESSAGES/djangojs.po b/conf/locale/ka/LC_MESSAGES/djangojs.po
index 20eaf4425c..5705e22be2 100644
--- a/conf/locale/ka/LC_MESSAGES/djangojs.po
+++ b/conf/locale/ka/LC_MESSAGES/djangojs.po
@@ -56,7 +56,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Lasha Kokilashvili, 2018\n"
"Language-Team: Georgian (http://app.transifex.com/open-edx/edx-platform/language/ka/)\n"
diff --git a/conf/locale/ko_KR/LC_MESSAGES/django.po b/conf/locale/ko_KR/LC_MESSAGES/django.po
index 88054ae64f..3992de5d8d 100644
--- a/conf/locale/ko_KR/LC_MESSAGES/django.po
+++ b/conf/locale/ko_KR/LC_MESSAGES/django.po
@@ -17370,10 +17370,6 @@ msgid ""
"subjects."
msgstr ""
-#: themes/edx.org/lms/templates/dashboard.html
-msgid "Take advantage of free coaching!"
-msgstr ""
-
#: themes/edx.org/lms/templates/dashboard.html
msgid "Get Started"
msgstr ""
diff --git a/conf/locale/lt_LT/LC_MESSAGES/django.po b/conf/locale/lt_LT/LC_MESSAGES/django.po
index af46587ba2..18fb6c32e4 100644
--- a/conf/locale/lt_LT/LC_MESSAGES/django.po
+++ b/conf/locale/lt_LT/LC_MESSAGES/django.po
@@ -72,7 +72,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Lithuanian (Lithuania) (https://app.transifex.com/open-edx/teams/6205/lt_LT/)\n"
@@ -3266,12 +3266,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6767,6 +6767,14 @@ msgstr ""
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6803,6 +6811,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/lt_LT/LC_MESSAGES/djangojs.po b/conf/locale/lt_LT/LC_MESSAGES/djangojs.po
index 4c162463f0..01e2a4c66e 100644
--- a/conf/locale/lt_LT/LC_MESSAGES/djangojs.po
+++ b/conf/locale/lt_LT/LC_MESSAGES/djangojs.po
@@ -50,7 +50,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Riina , 2014-2015\n"
"Language-Team: Lithuanian (Lithuania) (http://app.transifex.com/open-edx/edx-platform/language/lt_LT/)\n"
diff --git a/conf/locale/lv/LC_MESSAGES/django.po b/conf/locale/lv/LC_MESSAGES/django.po
index 20725bde01..5b107efa2f 100644
--- a/conf/locale/lv/LC_MESSAGES/django.po
+++ b/conf/locale/lv/LC_MESSAGES/django.po
@@ -49,7 +49,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Latvian (https://app.transifex.com/open-edx/teams/6205/lv/)\n"
@@ -3436,12 +3436,12 @@ msgid "Good"
msgstr "Labi"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7168,6 +7168,14 @@ msgstr "Ievietot valsti {country} melnajā sarakstā kursam {course}"
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7204,6 +7212,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/lv/LC_MESSAGES/djangojs.po b/conf/locale/lv/LC_MESSAGES/djangojs.po
index 4dea7b3416..584ef278f7 100644
--- a/conf/locale/lv/LC_MESSAGES/djangojs.po
+++ b/conf/locale/lv/LC_MESSAGES/djangojs.po
@@ -40,7 +40,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: LTMC Latvijas Tiesnešu mācību centrs , 2019\n"
"Language-Team: Latvian (http://app.transifex.com/open-edx/edx-platform/language/lv/)\n"
diff --git a/conf/locale/mn/LC_MESSAGES/django.po b/conf/locale/mn/LC_MESSAGES/django.po
index 562c794239..0960b5b29c 100644
--- a/conf/locale/mn/LC_MESSAGES/django.po
+++ b/conf/locale/mn/LC_MESSAGES/django.po
@@ -74,7 +74,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Mongolian (https://app.transifex.com/open-edx/teams/6205/mn/)\n"
@@ -3292,12 +3292,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6824,6 +6824,14 @@ msgstr "{country} улсыг {course} хичээлийн хар жагсаал
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6860,6 +6868,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/mn/LC_MESSAGES/djangojs.po b/conf/locale/mn/LC_MESSAGES/djangojs.po
index a5fbdfbfaa..08a83d7d7f 100644
--- a/conf/locale/mn/LC_MESSAGES/djangojs.po
+++ b/conf/locale/mn/LC_MESSAGES/djangojs.po
@@ -63,7 +63,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Myagmarjav Enkhbileg , 2018\n"
"Language-Team: Mongolian (http://app.transifex.com/open-edx/edx-platform/language/mn/)\n"
diff --git a/conf/locale/pl/LC_MESSAGES/django.po b/conf/locale/pl/LC_MESSAGES/django.po
index da1f4536ac..f8c273e0b6 100644
--- a/conf/locale/pl/LC_MESSAGES/django.po
+++ b/conf/locale/pl/LC_MESSAGES/django.po
@@ -150,7 +150,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Marcin Miłek, 2022\n"
"Language-Team: Polish (https://app.transifex.com/open-edx/teams/6205/pl/)\n"
@@ -3554,12 +3554,12 @@ msgid "Good"
msgstr "Dobrze"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7296,6 +7296,14 @@ msgstr "Dodaj kraj \"{country}\" do czarnej listy kursu {course}"
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7332,6 +7340,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/pl/LC_MESSAGES/djangojs.po b/conf/locale/pl/LC_MESSAGES/djangojs.po
index e9a391e6c7..98d7624c18 100644
--- a/conf/locale/pl/LC_MESSAGES/djangojs.po
+++ b/conf/locale/pl/LC_MESSAGES/djangojs.po
@@ -116,7 +116,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Klara Sielicka-Baryłka, 2022\n"
"Language-Team: Polish (http://app.transifex.com/open-edx/edx-platform/language/pl/)\n"
diff --git a/conf/locale/pt_BR/LC_MESSAGES/django.po b/conf/locale/pt_BR/LC_MESSAGES/django.po
index 6d9800d190..ed9a191556 100644
--- a/conf/locale/pt_BR/LC_MESSAGES/django.po
+++ b/conf/locale/pt_BR/LC_MESSAGES/django.po
@@ -19492,10 +19492,6 @@ msgid ""
"subjects."
msgstr ""
-#: themes/edx.org/lms/templates/dashboard.html
-msgid "Take advantage of free coaching!"
-msgstr ""
-
#: themes/edx.org/lms/templates/dashboard.html
msgid "Get Started"
msgstr ""
diff --git a/conf/locale/pt_BR/LC_MESSAGES/djangojs.po b/conf/locale/pt_BR/LC_MESSAGES/djangojs.po
index 80dd53ef4f..614635706f 100644
--- a/conf/locale/pt_BR/LC_MESSAGES/djangojs.po
+++ b/conf/locale/pt_BR/LC_MESSAGES/djangojs.po
@@ -246,7 +246,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Rodrigo Rocha , 2020\n"
"Language-Team: Portuguese (Brazil) (http://app.transifex.com/open-edx/edx-platform/language/pt_BR/)\n"
diff --git a/conf/locale/pt_PT/LC_MESSAGES/django.po b/conf/locale/pt_PT/LC_MESSAGES/django.po
index e6fe1acdb8..28569e7605 100644
--- a/conf/locale/pt_PT/LC_MESSAGES/django.po
+++ b/conf/locale/pt_PT/LC_MESSAGES/django.po
@@ -141,7 +141,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-10 20:42+0000\n"
+"POT-Creation-Date: 2023-10-01 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Cátia Lopes , 2019\n"
"Language-Team: Portuguese (Portugal) (https://app.transifex.com/open-edx/teams/6205/pt_PT/)\n"
@@ -3786,12 +3786,12 @@ msgid "Good"
msgstr "Bom"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7773,6 +7773,14 @@ msgstr "Incluir na lista negra {country} para o curso {course}"
msgid "Learner Pathways"
msgstr "Trajetos do Estudante"
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7809,6 +7817,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/pt_PT/LC_MESSAGES/djangojs.po b/conf/locale/pt_PT/LC_MESSAGES/djangojs.po
index a3ef571d2c..a784860dd6 100644
--- a/conf/locale/pt_PT/LC_MESSAGES/djangojs.po
+++ b/conf/locale/pt_PT/LC_MESSAGES/djangojs.po
@@ -113,7 +113,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Ivo Branco , 2021,2023\n"
"Language-Team: Portuguese (Portugal) (http://app.transifex.com/open-edx/edx-platform/language/pt_PT/)\n"
diff --git a/conf/locale/rtl/LC_MESSAGES/django.mo b/conf/locale/rtl/LC_MESSAGES/django.mo
index 8ad5d41434..65f38f3dde 100644
Binary files a/conf/locale/rtl/LC_MESSAGES/django.mo and b/conf/locale/rtl/LC_MESSAGES/django.mo differ
diff --git a/conf/locale/rtl/LC_MESSAGES/django.po b/conf/locale/rtl/LC_MESSAGES/django.po
index 85b4e8a6f3..abba53851d 100644
--- a/conf/locale/rtl/LC_MESSAGES/django.po
+++ b/conf/locale/rtl/LC_MESSAGES/django.po
@@ -38,8 +38,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-10-01 20:36+0000\n"
-"PO-Revision-Date: 2023-10-01 20:36:14.857987\n"
+"POT-Creation-Date: 2023-10-22 20:35+0000\n"
+"PO-Revision-Date: 2023-10-22 20:35:52.246505\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: rtl\n"
@@ -2027,68 +2027,6 @@ msgstr "Ŧɥǝ bɐdƃǝ ᴉɯɐƃǝ ɟᴉlǝ sᴉzǝ ɯnsʇ bǝ lǝss ʇɥɐn 25
msgid "This value must be all lowercase."
msgstr "Ŧɥᴉs ʌɐlnǝ ɯnsʇ bǝ ɐll løʍǝɹɔɐsǝ."
-#: lms/djangoapps/badges/models.py
-msgid "The course mode for this badge image. For example, \"verified\" or \"honor\"."
-msgstr "Ŧɥǝ ɔønɹsǝ ɯødǝ ɟøɹ ʇɥᴉs bɐdƃǝ ᴉɯɐƃǝ. Føɹ ǝxɐɯdlǝ, \"ʌǝɹᴉɟᴉǝd\" øɹ \"ɥønøɹ\"."
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Badge images must be square PNG files. The file size should be under 250KB."
-msgstr ""
-"Ƀɐdƃǝ ᴉɯɐƃǝs ɯnsʇ bǝ sbnɐɹǝ ⱣNǤ ɟᴉlǝs. Ŧɥǝ ɟᴉlǝ sᴉzǝ sɥønld bǝ nndǝɹ 250ꝀɃ."
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Set this value to True if you want this image to be the default image for "
-"any course modes that do not have a specified badge image. You can have only"
-" one default image."
-msgstr ""
-"Sǝʇ ʇɥᴉs ʌɐlnǝ ʇø Ŧɹnǝ ᴉɟ ʎøn ʍɐnʇ ʇɥᴉs ᴉɯɐƃǝ ʇø bǝ ʇɥǝ dǝɟɐnlʇ ᴉɯɐƃǝ ɟøɹ "
-"ɐnʎ ɔønɹsǝ ɯødǝs ʇɥɐʇ dø nøʇ ɥɐʌǝ ɐ sdǝɔᴉɟᴉǝd bɐdƃǝ ᴉɯɐƃǝ. Ɏøn ɔɐn ɥɐʌǝ ønlʎ"
-" ønǝ dǝɟɐnlʇ ᴉɯɐƃǝ."
-
-#: lms/djangoapps/badges/models.py
-msgid "There can be only one default image."
-msgstr "Ŧɥǝɹǝ ɔɐn bǝ ønlʎ ønǝ dǝɟɐnlʇ ᴉɯɐƃǝ."
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"On each line, put the number of completed courses to award a badge for, a "
-"comma, and the slug of a badge class you have created that has the issuing "
-"component 'openedx__course'. For example: 3,enrolled_3_courses"
-msgstr ""
-"Øn ǝɐɔɥ lᴉnǝ, dnʇ ʇɥǝ nnɯbǝɹ øɟ ɔøɯdlǝʇǝd ɔønɹsǝs ʇø ɐʍɐɹd ɐ bɐdƃǝ ɟøɹ, ɐ "
-"ɔøɯɯɐ, ɐnd ʇɥǝ slnƃ øɟ ɐ bɐdƃǝ ɔlɐss ʎøn ɥɐʌǝ ɔɹǝɐʇǝd ʇɥɐʇ ɥɐs ʇɥǝ ᴉssnᴉnƃ "
-"ɔøɯdønǝnʇ 'ødǝnǝdx__ɔønɹsǝ'. Føɹ ǝxɐɯdlǝ: 3,ǝnɹøllǝd_3_ɔønɹsǝs"
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"On each line, put the number of enrolled courses to award a badge for, a "
-"comma, and the slug of a badge class you have created that has the issuing "
-"component 'openedx__course'. For example: 3,enrolled_3_courses"
-msgstr ""
-"Øn ǝɐɔɥ lᴉnǝ, dnʇ ʇɥǝ nnɯbǝɹ øɟ ǝnɹøllǝd ɔønɹsǝs ʇø ɐʍɐɹd ɐ bɐdƃǝ ɟøɹ, ɐ "
-"ɔøɯɯɐ, ɐnd ʇɥǝ slnƃ øɟ ɐ bɐdƃǝ ɔlɐss ʎøn ɥɐʌǝ ɔɹǝɐʇǝd ʇɥɐʇ ɥɐs ʇɥǝ ᴉssnᴉnƃ "
-"ɔøɯdønǝnʇ 'ødǝnǝdx__ɔønɹsǝ'. Føɹ ǝxɐɯdlǝ: 3,ǝnɹøllǝd_3_ɔønɹsǝs"
-
-#: lms/djangoapps/badges/models.py
-msgid ""
-"Each line is a comma-separated list. The first item in each line is the slug"
-" of a badge class you have created that has an issuing component of "
-"'openedx__course'. The remaining items in each line are the course keys the "
-"learner needs to complete to be awarded the badge. For example: "
-"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second"
-msgstr ""
-"Ɇɐɔɥ lᴉnǝ ᴉs ɐ ɔøɯɯɐ-sǝdɐɹɐʇǝd lᴉsʇ. Ŧɥǝ ɟᴉɹsʇ ᴉʇǝɯ ᴉn ǝɐɔɥ lᴉnǝ ᴉs ʇɥǝ slnƃ"
-" øɟ ɐ bɐdƃǝ ɔlɐss ʎøn ɥɐʌǝ ɔɹǝɐʇǝd ʇɥɐʇ ɥɐs ɐn ᴉssnᴉnƃ ɔøɯdønǝnʇ øɟ "
-"'ødǝnǝdx__ɔønɹsǝ'. Ŧɥǝ ɹǝɯɐᴉnᴉnƃ ᴉʇǝɯs ᴉn ǝɐɔɥ lᴉnǝ ɐɹǝ ʇɥǝ ɔønɹsǝ ʞǝʎs ʇɥǝ "
-"lǝɐɹnǝɹ nǝǝds ʇø ɔøɯdlǝʇǝ ʇø bǝ ɐʍɐɹdǝd ʇɥǝ bɐdƃǝ. Føɹ ǝxɐɯdlǝ: "
-"slnƃ_ɟøɹ_ɔøɯdsɔᴉ_ɔønɹsǝs_ƃɹønd_bɐdƃǝ,ɔønɹsǝ-ʌ1:ȻøɯdSɔᴉ+Ȼønɹsǝ+Fᴉɹsʇ,ɔønɹsǝ-ʌ1:ȻøɯdsSɔᴉ+Ȼønɹsǝ+Sǝɔønd"
-
-#: lms/djangoapps/badges/models.py
-msgid "Please check the syntax of your entry."
-msgstr "Ᵽlǝɐsǝ ɔɥǝɔʞ ʇɥǝ sʎnʇɐx øɟ ʎønɹ ǝnʇɹʎ."
-
#. Translators: This string is used across Open edX installations
#. as a callback to edX. Please do not translate `edX.org`
#: lms/djangoapps/branding/api.py
@@ -3595,12 +3533,12 @@ msgid "Good"
msgstr "Ǥøød"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr "ʎønɹ"
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr "ʇɥǝᴉɹ"
@@ -7546,6 +7484,24 @@ msgstr ""
"<{p}><{strong}>{username}{strong}> ɐsʞǝd "
"<{strong}>{post_title}{strong}>{p}>"
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+"<{p}><{strong}>{replier_name}{strong}> ɹǝsdøndǝd ʇø ɐ døsʇ ʎøn’ɹǝ "
+"ɟølløʍᴉnƃ: <{strong}>{post_title}{strong}>{p}>"
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+"<{p}><{strong}>{replier_name}{strong}> ɔøɯɯǝnʇǝd øn {author_name}'s "
+"ɹǝsdønsǝ ᴉn ɐ døsʇ ʎøn’ɹǝ ɟølløʍᴉnƃ <{strong}>{post_title}{strong}>{p}>"
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
@@ -10770,18 +10726,6 @@ msgstr ""
"Ɇdᴉʇ ʇɥǝ nɐɯǝ øɟ ʇɥǝ ʌᴉdǝø ʇɥnɯbnɐᴉl ᴉɯɐƃǝ ɟᴉlǝ. Ɏøn ɔɐn sǝʇ ʇɥǝ ʌᴉdǝø "
"ʇɥnɯbnɐᴉl ᴉɯɐƃǝ øn ʇɥǝ Sǝʇʇᴉnƃs & Đǝʇɐᴉls dɐƃǝ."
-#: xmodule/course_block.py
-msgid "Issue Open Badges"
-msgstr "Ɨssnǝ Ødǝn Ƀɐdƃǝs"
-
-#: xmodule/course_block.py
-msgid ""
-"Issue Open Badges badges for this course. Badges are generated when "
-"certificates are created."
-msgstr ""
-"Ɨssnǝ Ødǝn Ƀɐdƃǝs bɐdƃǝs ɟøɹ ʇɥᴉs ɔønɹsǝ. Ƀɐdƃǝs ɐɹǝ ƃǝnǝɹɐʇǝd ʍɥǝn "
-"ɔǝɹʇᴉɟᴉɔɐʇǝs ɐɹǝ ɔɹǝɐʇǝd."
-
#: xmodule/course_block.py
msgid ""
"Use this setting only when generating PDF certificates. Between quotation "
@@ -21035,8 +20979,8 @@ msgid "Studio Accessibility Policy"
msgstr "Sʇndᴉø Ⱥɔɔǝssᴉbᴉlᴉʇʎ Ᵽølᴉɔʎ"
#: cms/templates/asset_index.html cms/templates/widgets/header.html
-msgid "Files & Uploads"
-msgstr "Fᴉlǝs & Ʉdløɐds"
+msgid "Files"
+msgstr "Fᴉlǝs"
#: cms/templates/certificates.html
msgid "Course Certificates"
@@ -22967,6 +22911,10 @@ msgstr "Ⱥɹɔɥᴉʌǝd Ȼønɹsǝs"
msgid "Libraries"
msgstr "Łᴉbɹɐɹᴉǝs"
+#: cms/templates/index.html
+msgid "Taxonomies"
+msgstr "Ŧɐxønøɯᴉǝs"
+
#: cms/templates/index.html
msgid "Are you staff on an existing {studio_name} course?"
msgstr "Ⱥɹǝ ʎøn sʇɐɟɟ øn ɐn ǝxᴉsʇᴉnƃ {studio_name} ɔønɹsǝ?"
diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.mo b/conf/locale/rtl/LC_MESSAGES/djangojs.mo
index 14aca7096c..e59d3dd99b 100644
Binary files a/conf/locale/rtl/LC_MESSAGES/djangojs.mo and b/conf/locale/rtl/LC_MESSAGES/djangojs.mo differ
diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.po b/conf/locale/rtl/LC_MESSAGES/djangojs.po
index 280f56e60c..8955ec2b9d 100644
--- a/conf/locale/rtl/LC_MESSAGES/djangojs.po
+++ b/conf/locale/rtl/LC_MESSAGES/djangojs.po
@@ -32,8 +32,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-10-01 20:36+0000\n"
-"PO-Revision-Date: 2023-10-01 20:36:14.819628\n"
+"POT-Creation-Date: 2023-10-22 20:35+0000\n"
+"PO-Revision-Date: 2023-10-22 20:35:52.488124\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: rtl\n"
@@ -132,7 +132,6 @@ msgstr "Ʉdløɐdᴉnƃ"
#: cms/templates/js/course-video-settings.underscore
#: common/static/common/templates/discussion/templates.underscore
#: common/static/common/templates/image-modal.underscore
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
msgid "Close"
msgstr "Ȼløsǝ"
@@ -4010,10 +4009,6 @@ msgstr ""
"Ŧǝll øʇɥǝɹ lǝɐɹnǝɹs ɐ lᴉʇʇlǝ ɐbønʇ ʎønɹsǝlɟ: ʍɥǝɹǝ ʎøn lᴉʌǝ, ʍɥɐʇ ʎønɹ "
"ᴉnʇǝɹǝsʇs ɐɹǝ, ʍɥʎ ʎøn'ɹǝ ʇɐʞᴉnƃ ɔønɹsǝs, øɹ ʍɥɐʇ ʎøn ɥødǝ ʇø lǝɐɹn."
-#: openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js
-msgid "Accomplishments Pagination"
-msgstr "Ⱥɔɔøɯdlᴉsɥɯǝnʇs Ᵽɐƃᴉnɐʇᴉøn"
-
#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js
msgid "Account Settings page."
msgstr "Ⱥɔɔønnʇ Sǝʇʇᴉnƃs dɐƃǝ."
@@ -4046,10 +4041,6 @@ msgstr "Ᵽɹøɟᴉlǝ ᴉɯɐƃǝ ɟøɹ {username}"
msgid "About Me"
msgstr "Ⱥbønʇ Mǝ"
-#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js
-msgid "Accomplishments"
-msgstr "Ⱥɔɔøɯdlᴉsɥɯǝnʇs"
-
#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js
msgid "Profile"
msgstr "Ᵽɹøɟᴉlǝ"
@@ -9848,32 +9839,6 @@ msgstr[1] "Łøɐd nǝxʇ {num_items} ɹǝsnlʇs"
msgid "Sorry, no results were found."
msgstr "Søɹɹʎ, nø ɹǝsnlʇs ʍǝɹǝ ɟønnd."
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-#, python-format
-msgid "Share your \"%(display_name)s\" award"
-msgstr "Sɥɐɹǝ ʎønɹ \"%(display_name)s\" ɐʍɐɹd"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-msgid "Share"
-msgstr "Sɥɐɹǝ"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore
-#, python-format
-msgid "Earned %(created)s."
-msgstr "Ɇɐɹnǝd %(created)s."
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "What's Your Next Accomplishment?"
-msgstr "Wɥɐʇ's Ɏønɹ Nǝxʇ Ⱥɔɔøɯdlᴉsɥɯǝnʇ?"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "Start working toward your next learning goal."
-msgstr "Sʇɐɹʇ ʍøɹʞᴉnƃ ʇøʍɐɹd ʎønɹ nǝxʇ lǝɐɹnᴉnƃ ƃøɐl."
-
-#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore
-msgid "Find a course"
-msgstr "Fᴉnd ɐ ɔønɹsǝ"
-
#: openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore
msgid "You are currently sharing a limited profile."
msgstr "Ɏøn ɐɹǝ ɔnɹɹǝnʇlʎ sɥɐɹᴉnƃ ɐ lᴉɯᴉʇǝd dɹøɟᴉlǝ."
@@ -9882,38 +9847,6 @@ msgstr "Ɏøn ɐɹǝ ɔnɹɹǝnʇlʎ sɥɐɹᴉnƃ ɐ lᴉɯᴉʇǝd dɹøɟᴉl
msgid "This learner is currently sharing a limited profile."
msgstr "Ŧɥᴉs lǝɐɹnǝɹ ᴉs ɔnɹɹǝnʇlʎ sɥɐɹᴉnƃ ɐ lᴉɯᴉʇǝd dɹøɟᴉlǝ."
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid "Share on Mozilla Backpack"
-msgstr "Sɥɐɹǝ øn Møzᴉllɐ Ƀɐɔʞdɐɔʞ"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"To share your certificate on Mozilla Backpack, you must first have a "
-"Backpack account. Complete the following steps to add your certificate to "
-"Backpack."
-msgstr ""
-"Ŧø sɥɐɹǝ ʎønɹ ɔǝɹʇᴉɟᴉɔɐʇǝ øn Møzᴉllɐ Ƀɐɔʞdɐɔʞ, ʎøn ɯnsʇ ɟᴉɹsʇ ɥɐʌǝ ɐ "
-"Ƀɐɔʞdɐɔʞ ɐɔɔønnʇ. Ȼøɯdlǝʇǝ ʇɥǝ ɟølløʍᴉnƃ sʇǝds ʇø ɐdd ʎønɹ ɔǝɹʇᴉɟᴉɔɐʇǝ ʇø "
-"Ƀɐɔʞdɐɔʞ."
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"Create a {link_start}Mozilla Backpack{link_end} account, or log in to your "
-"existing account"
-msgstr ""
-"Ȼɹǝɐʇǝ ɐ {link_start}Møzᴉllɐ Ƀɐɔʞdɐɔʞ{link_end} ɐɔɔønnʇ, øɹ løƃ ᴉn ʇø ʎønɹ "
-"ǝxᴉsʇᴉnƃ ɐɔɔønnʇ"
-
-#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore
-msgid ""
-"{download_link_start}Download this image (right-click or option-click, save "
-"as){link_end} and then {upload_link_start}upload{link_end} it to your "
-"backpack."
-msgstr ""
-"{download_link_start}Đøʍnløɐd ʇɥᴉs ᴉɯɐƃǝ (ɹᴉƃɥʇ-ɔlᴉɔʞ øɹ ødʇᴉøn-ɔlᴉɔʞ, sɐʌǝ "
-"ɐs){link_end} ɐnd ʇɥǝn {upload_link_start}ndløɐd{link_end} ᴉʇ ʇø ʎønɹ "
-"bɐɔʞdɐɔʞ."
-
#: cms/templates/js/access-editor.underscore
msgid "Limit Access"
msgstr "Łᴉɯᴉʇ Ⱥɔɔǝss"
diff --git a/conf/locale/ru/LC_MESSAGES/django.po b/conf/locale/ru/LC_MESSAGES/django.po
index c14da1dafc..6d2ab4968c 100644
--- a/conf/locale/ru/LC_MESSAGES/django.po
+++ b/conf/locale/ru/LC_MESSAGES/django.po
@@ -20296,10 +20296,6 @@ msgstr ""
"Ознакомьтесь с недавно запущенными курсами и новостями по вашим любимым "
"предметам."
-#: themes/edx.org/lms/templates/dashboard.html
-msgid "Take advantage of free coaching!"
-msgstr ""
-
#: themes/edx.org/lms/templates/dashboard.html
msgid "Get Started"
msgstr ""
diff --git a/conf/locale/ru/LC_MESSAGES/djangojs.po b/conf/locale/ru/LC_MESSAGES/djangojs.po
index 57e9096fc5..38aa24dafe 100644
--- a/conf/locale/ru/LC_MESSAGES/djangojs.po
+++ b/conf/locale/ru/LC_MESSAGES/djangojs.po
@@ -193,7 +193,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: ashed , 2022-2023\n"
"Language-Team: Russian (http://app.transifex.com/open-edx/edx-platform/language/ru/)\n"
diff --git a/conf/locale/sk/LC_MESSAGES/django.po b/conf/locale/sk/LC_MESSAGES/django.po
index a782279aa1..b628d93dde 100644
--- a/conf/locale/sk/LC_MESSAGES/django.po
+++ b/conf/locale/sk/LC_MESSAGES/django.po
@@ -55,7 +55,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Slovak (https://app.transifex.com/open-edx/teams/6205/sk/)\n"
@@ -3300,12 +3300,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6846,6 +6846,14 @@ msgstr ""
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6882,6 +6890,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/sk/LC_MESSAGES/djangojs.po b/conf/locale/sk/LC_MESSAGES/djangojs.po
index 1e707b179a..30ad50119f 100644
--- a/conf/locale/sk/LC_MESSAGES/djangojs.po
+++ b/conf/locale/sk/LC_MESSAGES/djangojs.po
@@ -46,7 +46,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: \n"
"Language-Team: Slovak (http://app.transifex.com/open-edx/edx-platform/language/sk/)\n"
diff --git a/conf/locale/sw_KE/LC_MESSAGES/django.po b/conf/locale/sw_KE/LC_MESSAGES/django.po
index 1500e6b165..ee60f36c44 100644
--- a/conf/locale/sw_KE/LC_MESSAGES/django.po
+++ b/conf/locale/sw_KE/LC_MESSAGES/django.po
@@ -85,7 +85,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Swahili (Kenya) (https://app.transifex.com/open-edx/teams/6205/sw_KE/)\n"
@@ -3337,12 +3337,12 @@ msgid "Good"
msgstr "Vizuri"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6890,6 +6890,14 @@ msgstr "Orodha ya {country} zisizokubaliwa kwenye {course}"
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6926,6 +6934,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/sw_KE/LC_MESSAGES/djangojs.po b/conf/locale/sw_KE/LC_MESSAGES/djangojs.po
index 52e1dcbeea..21be4a28b6 100644
--- a/conf/locale/sw_KE/LC_MESSAGES/djangojs.po
+++ b/conf/locale/sw_KE/LC_MESSAGES/djangojs.po
@@ -71,7 +71,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: YAHAYA MWAVURIZI , 2017\n"
"Language-Team: Swahili (Kenya) (http://app.transifex.com/open-edx/edx-platform/language/sw_KE/)\n"
diff --git a/conf/locale/th/LC_MESSAGES/django.po b/conf/locale/th/LC_MESSAGES/django.po
index 91bee06809..d1d3abd290 100644
--- a/conf/locale/th/LC_MESSAGES/django.po
+++ b/conf/locale/th/LC_MESSAGES/django.po
@@ -116,7 +116,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Thai (https://app.transifex.com/open-edx/teams/6205/th/)\n"
@@ -3226,12 +3226,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6705,6 +6705,14 @@ msgstr ""
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6741,6 +6749,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/th/LC_MESSAGES/djangojs.po b/conf/locale/th/LC_MESSAGES/djangojs.po
index 727ea417a2..3c103e1416 100644
--- a/conf/locale/th/LC_MESSAGES/djangojs.po
+++ b/conf/locale/th/LC_MESSAGES/djangojs.po
@@ -73,7 +73,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: edx demo , 2019\n"
"Language-Team: Thai (http://app.transifex.com/open-edx/edx-platform/language/th/)\n"
diff --git a/conf/locale/tr_TR/LC_MESSAGES/django.po b/conf/locale/tr_TR/LC_MESSAGES/django.po
index 99388c329a..8d02cfb30a 100644
--- a/conf/locale/tr_TR/LC_MESSAGES/django.po
+++ b/conf/locale/tr_TR/LC_MESSAGES/django.po
@@ -132,7 +132,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-10 20:42+0000\n"
+"POT-Creation-Date: 2023-10-01 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Ali Işıngör , 2021\n"
"Language-Team: Turkish (Turkey) (https://app.transifex.com/open-edx/teams/6205/tr_TR/)\n"
@@ -3708,12 +3708,12 @@ msgid "Good"
msgstr "İyi"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr "size"
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr "onlara"
@@ -7639,6 +7639,14 @@ msgstr "{course} dersi için {country} kara listede"
msgid "Learner Pathways"
msgstr "Öğrenci Patikaları"
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7681,6 +7689,20 @@ msgstr ""
"<{p}><{strong}>{username}{strong}> sordu "
"<{strong}>{post_title}{strong}>{p}>"
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/tr_TR/LC_MESSAGES/djangojs.po b/conf/locale/tr_TR/LC_MESSAGES/djangojs.po
index b8e157cc2b..e7898c1222 100644
--- a/conf/locale/tr_TR/LC_MESSAGES/djangojs.po
+++ b/conf/locale/tr_TR/LC_MESSAGES/djangojs.po
@@ -110,7 +110,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Ali Işıngör , 2018,2020-2021,2023\n"
"Language-Team: Turkish (Turkey) (http://app.transifex.com/open-edx/edx-platform/language/tr_TR/)\n"
diff --git a/conf/locale/uk/LC_MESSAGES/django.po b/conf/locale/uk/LC_MESSAGES/django.po
index 9df1d2c5ea..3bcc098c5d 100644
--- a/conf/locale/uk/LC_MESSAGES/django.po
+++ b/conf/locale/uk/LC_MESSAGES/django.po
@@ -18,6 +18,7 @@
# IvanPrimachenko , 2014
# Martin Horst , 2018
# Mykhailo Parfeniuk , 2016
+# Mykola Melnyk, 2023
# Natalia Vynogradenko , 2017-2018
# Nikolai Belochub, 2014
# cc322e8fc6cc292f0f00d8b7420e7410_e468d09 <95dee59251c2de5d1d21e213a66df19d_243275>, 2014
@@ -125,7 +126,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Danylo Shcherbak , 2020\n"
"Language-Team: Ukrainian (https://app.transifex.com/open-edx/teams/6205/uk/)\n"
@@ -3572,12 +3573,12 @@ msgid "Good"
msgstr "Добре"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -7339,6 +7340,14 @@ msgstr "Занести до чорного списку країну {country}
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7375,6 +7384,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/uk/LC_MESSAGES/djangojs.po b/conf/locale/uk/LC_MESSAGES/djangojs.po
index de9cd2e85d..bfadf2ecc6 100644
--- a/conf/locale/uk/LC_MESSAGES/djangojs.po
+++ b/conf/locale/uk/LC_MESSAGES/djangojs.po
@@ -102,7 +102,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Andrey Kryachko, 2018\n"
"Language-Team: Ukrainian (http://app.transifex.com/open-edx/edx-platform/language/uk/)\n"
diff --git a/conf/locale/vi/LC_MESSAGES/django.po b/conf/locale/vi/LC_MESSAGES/django.po
index 6b2785be79..ffd88f1030 100644
--- a/conf/locale/vi/LC_MESSAGES/django.po
+++ b/conf/locale/vi/LC_MESSAGES/django.po
@@ -198,7 +198,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Le Minh Tri , 2020\n"
"Language-Team: Vietnamese (https://app.transifex.com/open-edx/teams/6205/vi/)\n"
@@ -3300,12 +3300,12 @@ msgid "Good"
msgstr ""
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6783,6 +6783,14 @@ msgstr ""
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6819,6 +6827,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/vi/LC_MESSAGES/djangojs.po b/conf/locale/vi/LC_MESSAGES/djangojs.po
index 468215f6fd..64fdeb1dd0 100644
--- a/conf/locale/vi/LC_MESSAGES/djangojs.po
+++ b/conf/locale/vi/LC_MESSAGES/djangojs.po
@@ -113,7 +113,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Le Minh Tri , 2020\n"
"Language-Team: Vietnamese (http://app.transifex.com/open-edx/edx-platform/language/vi/)\n"
diff --git a/conf/locale/zh_CN/LC_MESSAGES/django.mo b/conf/locale/zh_CN/LC_MESSAGES/django.mo
index 52436b2f38..9dd98ee075 100644
Binary files a/conf/locale/zh_CN/LC_MESSAGES/django.mo and b/conf/locale/zh_CN/LC_MESSAGES/django.mo differ
diff --git a/conf/locale/zh_CN/LC_MESSAGES/django.po b/conf/locale/zh_CN/LC_MESSAGES/django.po
index 5727a7015a..8829d9b568 100644
--- a/conf/locale/zh_CN/LC_MESSAGES/django.po
+++ b/conf/locale/zh_CN/LC_MESSAGES/django.po
@@ -403,7 +403,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: ifLab , 2019\n"
"Language-Team: Chinese (China) (https://app.transifex.com/open-edx/teams/6205/zh_CN/)\n"
@@ -525,13 +525,13 @@ msgstr "空白"
#: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
#: openedx/core/djangoapps/xblock/rest_api/views.py
msgid "Invalid data"
-msgstr ""
+msgstr "无效的数据"
#: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
#: openedx/core/djangoapps/xblock/rest_api/views.py
#, python-brace-format
msgid "Invalid data ({details})"
-msgstr ""
+msgstr "无效的数据 ({details})"
#: cms/templates/admin/base_site.html lms/templates/admin/base_site.html
msgid "Django site admin"
@@ -3629,12 +3629,12 @@ msgid "Good"
msgstr "好"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6797,6 +6797,7 @@ msgstr ""
msgid "Original usage key/ID of the thing that is in the clipboard."
msgstr ""
+#: openedx/core/djangoapps/content_tagging/models/base.py
#: wiki/models/article.py
msgid "owner"
msgstr "所有者"
@@ -7234,6 +7235,14 @@ msgstr "课程{course}的黑名单国家: {country}"
msgid "Learner Pathways"
msgstr "学习者路径"
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7270,6 +7279,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
@@ -11951,19 +11974,19 @@ msgstr "课程团队用户不存在"
#, python-brace-format
msgid ""
"Number of course outline regenerations successfully requested: {regenerates}"
-msgstr ""
+msgstr "成功请求的课程大纲重新生成数:{regenerates}"
#: cms/djangoapps/contentstore/admin.py
msgid "Regenerate selected course outlines"
-msgstr ""
+msgstr "重新生成选定的课程大纲"
#: cms/djangoapps/contentstore/admin.py
msgid "All course outline regenerations successfully requested."
-msgstr ""
+msgstr "已成功请求所有课程大纲重新生成。"
#: cms/djangoapps/contentstore/admin.py
msgid "Regenerate *all* course outlines"
-msgstr ""
+msgstr "重新生成*所有*课程大纲"
#: cms/djangoapps/contentstore/asset_storage_handlers.py
msgid "Upload completed"
@@ -12025,11 +12048,11 @@ msgstr "(未命名)"
#: cms/djangoapps/contentstore/errors.py
msgid "Aborting import because a course with this id: {} already exists."
-msgstr ""
+msgstr "正在中止导入,因为具有此 ID 的课程:{} 已经存在。"
#: cms/djangoapps/contentstore/errors.py
msgid "Permission denied. You do not have write access to this course."
-msgstr ""
+msgstr "您没有对此课程的写入权限。"
#: cms/djangoapps/contentstore/errors.py
#, python-brace-format
@@ -12038,7 +12061,7 @@ msgstr "无法在包中找到{0}文件。"
#: cms/djangoapps/contentstore/errors.py
msgid "Uploaded Tar file not found. Try again."
-msgstr ""
+msgstr "找不到上传文件。再试一次。"
#: cms/djangoapps/contentstore/errors.py
#: cms/djangoapps/contentstore/views/import_export.py
@@ -12047,11 +12070,11 @@ msgstr "我们仅支持上传.tar.gz格式文件。"
#: cms/djangoapps/contentstore/errors.py
msgid "Aborting import since a library with this id already exists."
-msgstr ""
+msgstr "中止导入,因为具有此id的文件已存在。"
#: cms/djangoapps/contentstore/errors.py
msgid "Course olx validation failed. Please check your email."
-msgstr ""
+msgstr "课程验证失败。请查看你的邮箱。"
#: cms/djangoapps/contentstore/errors.py
msgid "Permission denied"
@@ -12059,11 +12082,11 @@ msgstr "没有权限"
#: cms/djangoapps/contentstore/errors.py
msgid "Unknown error while importing course."
-msgstr ""
+msgstr "导入课程时出现未知错误。"
#: cms/djangoapps/contentstore/errors.py
msgid "An Unknown error occurred during the unpacking step."
-msgstr ""
+msgstr "解压包过程中发生未知错误。"
#: cms/djangoapps/contentstore/errors.py
#, python-brace-format
@@ -12091,6 +12114,7 @@ msgid ""
"Non writable git url provided. Expecting something like: "
"git@github.com:openedx/openedx-demo-course.git"
msgstr ""
+"提供的GIT URL地址不正确。正确内容比如如下:git@github.com:openedx/openedx-demo-course.git"
#: cms/djangoapps/contentstore/git_export_utils.py
msgid ""
@@ -12158,39 +12182,39 @@ msgstr ""
#: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py
msgid "Marks a field as deprecated."
-msgstr ""
+msgstr "标记的字段已弃用。"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py
msgid "User-friendly display name for the field"
-msgstr ""
+msgstr "该用户名的字段显示"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py
msgid "Help text that describes the setting."
-msgstr ""
+msgstr "描述设置的帮助文本。"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "Tab type"
-msgstr ""
+msgstr "标签类型"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "Default name for the tab displayed to users"
-msgstr ""
+msgstr "向用户显示的选项卡的默认名称"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True if it's possible to hide the tab for a course"
-msgstr ""
+msgstr "此课程可能设置隐藏"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True the tab is hidden for the course"
-msgstr ""
+msgstr "课程设置为隐藏"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True if it's possible to reorder the tab in the list of tabs"
-msgstr ""
+msgstr "可在选项卡列表中可能设置重新排序选项卡,"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True if this tab should be displayed only for instructors"
-msgstr ""
+msgstr "此选项卡只应为教师"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "Name of the tab displayed to users. Overrides title."
diff --git a/conf/locale/zh_CN/LC_MESSAGES/djangojs.po b/conf/locale/zh_CN/LC_MESSAGES/djangojs.po
index 88327c771f..843d2345e1 100644
--- a/conf/locale/zh_CN/LC_MESSAGES/djangojs.po
+++ b/conf/locale/zh_CN/LC_MESSAGES/djangojs.po
@@ -230,7 +230,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: jsgang , 2015-2017,2020\n"
"Language-Team: Chinese (China) (http://app.transifex.com/open-edx/edx-platform/language/zh_CN/)\n"
diff --git a/conf/locale/zh_HANS/LC_MESSAGES/django.mo b/conf/locale/zh_HANS/LC_MESSAGES/django.mo
index 52436b2f38..9dd98ee075 100644
Binary files a/conf/locale/zh_HANS/LC_MESSAGES/django.mo and b/conf/locale/zh_HANS/LC_MESSAGES/django.mo differ
diff --git a/conf/locale/zh_HANS/LC_MESSAGES/django.po b/conf/locale/zh_HANS/LC_MESSAGES/django.po
index 5727a7015a..8829d9b568 100644
--- a/conf/locale/zh_HANS/LC_MESSAGES/django.po
+++ b/conf/locale/zh_HANS/LC_MESSAGES/django.po
@@ -403,7 +403,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: ifLab , 2019\n"
"Language-Team: Chinese (China) (https://app.transifex.com/open-edx/teams/6205/zh_CN/)\n"
@@ -525,13 +525,13 @@ msgstr "空白"
#: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
#: openedx/core/djangoapps/xblock/rest_api/views.py
msgid "Invalid data"
-msgstr ""
+msgstr "无效的数据"
#: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
#: openedx/core/djangoapps/xblock/rest_api/views.py
#, python-brace-format
msgid "Invalid data ({details})"
-msgstr ""
+msgstr "无效的数据 ({details})"
#: cms/templates/admin/base_site.html lms/templates/admin/base_site.html
msgid "Django site admin"
@@ -3629,12 +3629,12 @@ msgid "Good"
msgstr "好"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6797,6 +6797,7 @@ msgstr ""
msgid "Original usage key/ID of the thing that is in the clipboard."
msgstr ""
+#: openedx/core/djangoapps/content_tagging/models/base.py
#: wiki/models/article.py
msgid "owner"
msgstr "所有者"
@@ -7234,6 +7235,14 @@ msgstr "课程{course}的黑名单国家: {country}"
msgid "Learner Pathways"
msgstr "学习者路径"
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -7270,6 +7279,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
@@ -11951,19 +11974,19 @@ msgstr "课程团队用户不存在"
#, python-brace-format
msgid ""
"Number of course outline regenerations successfully requested: {regenerates}"
-msgstr ""
+msgstr "成功请求的课程大纲重新生成数:{regenerates}"
#: cms/djangoapps/contentstore/admin.py
msgid "Regenerate selected course outlines"
-msgstr ""
+msgstr "重新生成选定的课程大纲"
#: cms/djangoapps/contentstore/admin.py
msgid "All course outline regenerations successfully requested."
-msgstr ""
+msgstr "已成功请求所有课程大纲重新生成。"
#: cms/djangoapps/contentstore/admin.py
msgid "Regenerate *all* course outlines"
-msgstr ""
+msgstr "重新生成*所有*课程大纲"
#: cms/djangoapps/contentstore/asset_storage_handlers.py
msgid "Upload completed"
@@ -12025,11 +12048,11 @@ msgstr "(未命名)"
#: cms/djangoapps/contentstore/errors.py
msgid "Aborting import because a course with this id: {} already exists."
-msgstr ""
+msgstr "正在中止导入,因为具有此 ID 的课程:{} 已经存在。"
#: cms/djangoapps/contentstore/errors.py
msgid "Permission denied. You do not have write access to this course."
-msgstr ""
+msgstr "您没有对此课程的写入权限。"
#: cms/djangoapps/contentstore/errors.py
#, python-brace-format
@@ -12038,7 +12061,7 @@ msgstr "无法在包中找到{0}文件。"
#: cms/djangoapps/contentstore/errors.py
msgid "Uploaded Tar file not found. Try again."
-msgstr ""
+msgstr "找不到上传文件。再试一次。"
#: cms/djangoapps/contentstore/errors.py
#: cms/djangoapps/contentstore/views/import_export.py
@@ -12047,11 +12070,11 @@ msgstr "我们仅支持上传.tar.gz格式文件。"
#: cms/djangoapps/contentstore/errors.py
msgid "Aborting import since a library with this id already exists."
-msgstr ""
+msgstr "中止导入,因为具有此id的文件已存在。"
#: cms/djangoapps/contentstore/errors.py
msgid "Course olx validation failed. Please check your email."
-msgstr ""
+msgstr "课程验证失败。请查看你的邮箱。"
#: cms/djangoapps/contentstore/errors.py
msgid "Permission denied"
@@ -12059,11 +12082,11 @@ msgstr "没有权限"
#: cms/djangoapps/contentstore/errors.py
msgid "Unknown error while importing course."
-msgstr ""
+msgstr "导入课程时出现未知错误。"
#: cms/djangoapps/contentstore/errors.py
msgid "An Unknown error occurred during the unpacking step."
-msgstr ""
+msgstr "解压包过程中发生未知错误。"
#: cms/djangoapps/contentstore/errors.py
#, python-brace-format
@@ -12091,6 +12114,7 @@ msgid ""
"Non writable git url provided. Expecting something like: "
"git@github.com:openedx/openedx-demo-course.git"
msgstr ""
+"提供的GIT URL地址不正确。正确内容比如如下:git@github.com:openedx/openedx-demo-course.git"
#: cms/djangoapps/contentstore/git_export_utils.py
msgid ""
@@ -12158,39 +12182,39 @@ msgstr ""
#: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py
msgid "Marks a field as deprecated."
-msgstr ""
+msgstr "标记的字段已弃用。"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py
msgid "User-friendly display name for the field"
-msgstr ""
+msgstr "该用户名的字段显示"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py
msgid "Help text that describes the setting."
-msgstr ""
+msgstr "描述设置的帮助文本。"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "Tab type"
-msgstr ""
+msgstr "标签类型"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "Default name for the tab displayed to users"
-msgstr ""
+msgstr "向用户显示的选项卡的默认名称"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True if it's possible to hide the tab for a course"
-msgstr ""
+msgstr "此课程可能设置隐藏"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True the tab is hidden for the course"
-msgstr ""
+msgstr "课程设置为隐藏"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True if it's possible to reorder the tab in the list of tabs"
-msgstr ""
+msgstr "可在选项卡列表中可能设置重新排序选项卡,"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "True if this tab should be displayed only for instructors"
-msgstr ""
+msgstr "此选项卡只应为教师"
#: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py
msgid "Name of the tab displayed to users. Overrides title."
diff --git a/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po b/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po
index 88327c771f..843d2345e1 100644
--- a/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po
+++ b/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po
@@ -230,7 +230,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: jsgang , 2015-2017,2020\n"
"Language-Team: Chinese (China) (http://app.transifex.com/open-edx/edx-platform/language/zh_CN/)\n"
diff --git a/conf/locale/zh_TW/LC_MESSAGES/django.po b/conf/locale/zh_TW/LC_MESSAGES/django.po
index 43c41e5e9c..40972435e7 100644
--- a/conf/locale/zh_TW/LC_MESSAGES/django.po
+++ b/conf/locale/zh_TW/LC_MESSAGES/django.po
@@ -177,7 +177,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:43+0000\n"
+"POT-Creation-Date: 2023-10-08 20:43+0000\n"
"PO-Revision-Date: 2019-01-20 20:43+0000\n"
"Last-Translator: Waheed Ahmed , 2019\n"
"Language-Team: Chinese (Taiwan) (https://app.transifex.com/open-edx/teams/6205/zh_TW/)\n"
@@ -3341,12 +3341,12 @@ msgid "Good"
msgstr "好"
#. Translators: Replier commented on "your" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "your"
msgstr ""
#. Translators: Replier commented on "their" response to your post
-#: lms/djangoapps/discussion/rest_api/utils.py
+#: lms/djangoapps/discussion/rest_api/discussions_notifications.py
msgid "their"
msgstr ""
@@ -6871,6 +6871,14 @@ msgstr "課程{course}的黑名單國家: {country} "
msgid "Learner Pathways"
msgstr ""
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification App"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/admin.py
+msgid "Notification Type"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
#, python-brace-format
msgid ""
@@ -6907,6 +6915,20 @@ msgid ""
"<{strong}>{post_title}{strong}>{p}>"
msgstr ""
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> responded to a post you’re "
+"following: <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
+#: openedx/core/djangoapps/notifications/base_notification.py
+#, python-brace-format
+msgid ""
+"<{p}><{strong}>{replier_name}{strong}> commented on {author_name}'s "
+"response in a post you’re following <{strong}>{post_title}{strong}>{p}>"
+msgstr ""
+
#: openedx/core/djangoapps/notifications/base_notification.py
msgid ""
"Notifications for responses and comments on your posts, and the ones you’re "
diff --git a/conf/locale/zh_TW/LC_MESSAGES/djangojs.po b/conf/locale/zh_TW/LC_MESSAGES/djangojs.po
index 15d153824a..91dd729e09 100644
--- a/conf/locale/zh_TW/LC_MESSAGES/djangojs.po
+++ b/conf/locale/zh_TW/LC_MESSAGES/djangojs.po
@@ -132,7 +132,7 @@ msgid ""
msgstr ""
"Project-Id-Version: edx-platform\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2023-09-17 20:42+0000\n"
+"POT-Creation-Date: 2023-10-08 20:42+0000\n"
"PO-Revision-Date: 2014-06-11 15:18+0000\n"
"Last-Translator: Andrew Lau , 2017\n"
"Language-Team: Chinese (Taiwan) (http://app.transifex.com/open-edx/edx-platform/language/zh_TW/)\n"
diff --git a/docs/decisions/0018-standarize-django-po-files.rst b/docs/decisions/0018-standarize-django-po-files.rst
new file mode 100644
index 0000000000..4020b7c493
--- /dev/null
+++ b/docs/decisions/0018-standarize-django-po-files.rst
@@ -0,0 +1,155 @@
+Standardize ``django.po`` and ``djangojs.po`` files
+===================================================
+
+Status
+------
+
+Accepted
+
+Context
+-------
+
+- The edx-platform splits its translations into ``mako-studio.po``,
+ ``django-partial.po``, and 9 other files. This was done with the
+ intention of supporting more languages in the LMS than in
+ the CMS (Studio).
+
+- The edx-platform uses the ``i18n_tools segment`` to split the ``django.po``
+ and ``djangojs.po`` files into smaller files. This is done with the goal of
+ making it easier for translators to translate the files.
+
+- When pulling from Transifex or cutting a new release, the translations are
+ bundled into 2 files (``django.po`` and ``djangojs.po``).
+
+- The `FC-0012 project `_
+ which implements `Translation Infrastructure update OEP-58`_
+ is in progress. Upon completion, all translation files will live in
+ the new `openedx-translations Transifex project`_.
+
+- Consequently, repositories like ``edx-platform`` will pull translations via
+ `openedx-atlas`_ command line tool as described in the `OEP-58`_ proposal.
+
+- Several special steps would be needed to for the
+ `extract-translation-source-files.yml`_ GitHub workflow in
+ the `openedx-translations GitHub repository`_
+ to extract the files from the ``edx-platform`` repo.
+ This seems redundant and and it should be possible to simplify the process.
+
+Decision: Only use two files for edx-platform translations (``django.po`` and ``djangojs.po``)
+----------------------------------------------------------------------------------------------
+
+edX Platform will push only two files (``django.po`` and
+``djangojs.``) to the `openedx-translations Transifex project`_.
+
+Consequences
+------------
+
+Pros:
+
+- Translators will need to locate only two resources for the ``edx-platform``
+ repo as opposed to 11 resources.
+- Simplify the ``edx-platform`` translation extraction scripts in the
+ `extract-translation-source-files.yml`_ GitHub workflow.
+- The release cut process will be simplified as well by removing the
+ translation merger step altogether.
+- Translators will need one workflow for both latest (master) and named
+ releases for the ``edx-platform`` repo.
+- Simplifies the work of both the Build Test Release and Transifex working groups.
+
+Cons:
+
+- It will be harder for translators to focus on learner-facing text and de-prioritize
+ educator-facing text.
+
+
+Rejected Alternatives
+---------------------
+
+Combine into four files
+^^^^^^^^^^^^^^^^^^^^^^^
+
+This option tries to keep the original idea of splitting to allow translators
+to focus on learner-facing text and de-prioritize educator-facing text.
+
+The four files will be ``platform.po``, ``platform-js.po``, ``studio.po``
+and ``studio-js.po``.
+
+It will not concern the translators with the technical split
+of the files into: ``wiki.po``, ``mako.po``, and ``django-partial.po``, etc.
+
+
+In this option we'll create two new configuration files
+``config.extract-oep58.yaml`` and ``config.pull-oep58.yaml`` to combine the
+pofiles into the four files and then combine them into two files respectively:
+
+.. code:: yaml
+
+ # config.extract-oep58.yaml
+ # This file is used by the ``make extract_translations`` when
+ # the OPENEDX_COMBINE_FILES environment variable is enabled.
+ generate_merge:
+ platform.po:
+ - django-partial.po
+ - mako.po
+ - wiki.po
+ - edx_proctoring_proctortrack.po
+ platform-js.po:
+ - djangojs-partial.po
+ - djangojs-account-settings-view.po
+ - underscore.po
+ studio.po:
+ - django-studio.po
+ - mako-studio.po
+ studio-js.po:
+ - djangojs-studio.po
+ - underscore-studio.po
+
+A corresponding ``Makefile`` change is needed:
+
+.. code:: make
+
+ extract_translations: ## extract localizable strings from sources
+ i18n_tool extract -v
+ if [ -z "$$OPENEDX_COMBINE_FILES" ]; then \
+ i18n_tool generate --config=config.extract-oep58.yaml --verbose 1;
+ fi
+
+The other file would be ran after ``make pull_translations`` which will
+be used to make the final ``django.po`` and ``djangojs.po`` files that are
+usable by Django:
+
+.. code:: yaml
+
+ # config.pull-oep58.yaml
+ # This file is used by the ``make pull_translations`` when
+ # the OPENEDX_ATLAS_PULL environment variable is enabled.
+ generate_merge:
+ django.po:
+ - platform.po
+ - studio.po
+ djangojs.po:
+ - platform-js.po
+ - studio-js.po
+
+
+ pull_translations: ## extract localizable strings from sources
+ if [ -z "$$OPENEDX_ATLAS_PULL" ]; then \
+ atlas pull translations/edx-platform/conf/locale
+ i18n_tool --config=config.pull-oep58.yaml generate --verbose 1;
+ fi
+
+
+This option involves multiple merge and split steps which adds complexity
+for developers. Based on the `feedback in the decision pull request`_,
+splitting the resources was a lesser used feature in the Open edX community.
+Therefore, this option is rejected because the added complexity of this
+option isn't justified.
+
+
+.. _extract-translation-source-files.yml: https://github.com/openedx/openedx-translations/blob/2566e0c9a30d033e5dd8d05d4c12601c8e37b4ef/.github/workflows/extract-translation-source-files.yml
+.. _openedx-translations GitHub repository: https://github.com/openedx/openedx-translations
+.. _openedx-translations Transifex project: https://app.transifex.com/open-edx/openedx-translations/
+.. _OEP-58: https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification
+.. _openedx-atlas: https://github.com/openedx/openedx-atlas/
+.. _Translation Infrastructure update OEP-58: https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification
+.. _feedback in the decision pull request: https://github.com/openedx/edx-platform/pull/32994#issuecomment-1677390405
diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml
index 202b983212..9d95b947ae 100644
--- a/docs/lms-openapi.yaml
+++ b/docs/lms-openapi.yaml
@@ -58,114 +58,6 @@ paths:
in: path
required: true
type: string
- /badges/v1/assertions/user/{username}/:
- get:
- operationId: badges_v1_assertions_user_read
- summary: '**Use Cases**'
- description: |-
- Request a list of assertions for a user, optionally constrained to a course.
-
- **Example Requests**
-
- GET /api/badges/v1/assertions/user/{username}/
-
- **Response Values**
-
- Body comprised of a list of objects with the following fields:
-
- * badge_class: The badge class the assertion was awarded for. Represented as an object
- with the following fields:
- * slug: The identifier for the badge class
- * issuing_component: The software component responsible for issuing this badge.
- * display_name: The display name of the badge.
- * course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course.
- * description: A description of the award and its significance.
- * criteria: A description of what is needed to obtain this award.
- * image_url: A URL to the icon image used to represent this award.
- * image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award
- in its headers.
- * assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools
- and software.
-
- **Params**
-
- * slug (optional): The identifier for a particular badge class to filter by.
- * issuing_component (optional): The issuing component for a particular badge class to filter by
- (requires slug to have been specified, or this will be ignored.) If slug is provided and this is not,
- assumes the issuing_component should be empty.
- * course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is
- provided, and this field is not specified, assumes that the target badge has an empty course_id field.
- '*' may be used to get all badges with the specified slug, issuing_component combination across all courses.
-
- **Returns**
-
- * 200 on success, with a list of Badge Assertion objects.
- * 403 if a user who does not have permission to masquerade as
- another user specifies a username other than their own.
- * 404 if the specified user does not exist
-
- {
- "count": 7,
- "previous": null,
- "num_pages": 1,
- "results": [
- {
- "badge_class": {
- "slug": "special_award",
- "issuing_component": "openedx__course",
- "display_name": "Very Special Award",
- "course_id": "course-v1:edX+DemoX+Demo_Course",
- "description": "Awarded for people who did something incredibly special",
- "criteria": "Do something incredibly special.",
- "image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png"
- },
- "image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png",
- "assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6"
- },
- ...
- ]
- }
- parameters:
- - name: page
- in: query
- description: A page number within the paginated result set.
- required: false
- type: integer
- - name: page_size
- in: query
- description: Number of results to return per page.
- required: false
- type: integer
- responses:
- '200':
- description: ''
- schema:
- required:
- - count
- - results
- type: object
- properties:
- count:
- type: integer
- next:
- type: string
- format: uri
- x-nullable: true
- previous:
- type: string
- format: uri
- x-nullable: true
- results:
- type: array
- items:
- $ref: '#/definitions/BadgeAssertion'
- tags:
- - badges
- parameters:
- - name: username
- in: path
- required: true
- type: string
/bookmarks/v1/bookmarks/:
get:
operationId: bookmarks_v1_bookmarks_list
@@ -9477,75 +9369,6 @@ paths:
required: true
type: string
definitions:
- BadgeClass:
- required:
- - slug
- - display_name
- - description
- - criteria
- type: object
- properties:
- slug:
- title: Slug
- type: string
- format: slug
- pattern: ^[-a-zA-Z0-9_]+$
- maxLength: 255
- minLength: 1
- issuing_component:
- title: Issuing component
- type: string
- format: slug
- pattern: ^[-a-zA-Z0-9_]+$
- default: ''
- maxLength: 50
- display_name:
- title: Display name
- type: string
- maxLength: 255
- minLength: 1
- course_id:
- title: Course id
- type: string
- maxLength: 255
- description:
- title: Description
- type: string
- minLength: 1
- criteria:
- title: Criteria
- type: string
- minLength: 1
- image_url:
- title: Image url
- type: string
- readOnly: true
- format: uri
- BadgeAssertion:
- required:
- - image_url
- - assertion_url
- type: object
- properties:
- badge_class:
- $ref: '#/definitions/BadgeClass'
- image_url:
- title: Image url
- type: string
- format: uri
- maxLength: 200
- minLength: 1
- assertion_url:
- title: Assertion url
- type: string
- format: uri
- maxLength: 200
- minLength: 1
- created:
- title: Created
- type: string
- format: date-time
- readOnly: true
CCXCourse:
required:
- master_course_id
diff --git a/docs/references/docstrings/lms_index.rst b/docs/references/docstrings/lms_index.rst
index 108a65cd39..43676bfdc1 100644
--- a/docs/references/docstrings/lms_index.rst
+++ b/docs/references/docstrings/lms_index.rst
@@ -9,7 +9,6 @@ Studio.
:maxdepth: 2
lms/modules
- lms/djangoapps/badges/modules
lms/djangoapps/branding/modules
lms/djangoapps/bulk_email/modules
lms/djangoapps/courseware/modules
diff --git a/lms/djangoapps/badges/admin.py b/lms/djangoapps/badges/admin.py
deleted file mode 100644
index 096ac6f92c..0000000000
--- a/lms/djangoapps/badges/admin.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""
-Admin registration for Badge Models
-"""
-
-
-from config_models.admin import ConfigurationModelAdmin
-from django.contrib import admin
-
-from lms.djangoapps.badges.models import (
- BadgeAssertion,
- BadgeClass,
- CourseCompleteImageConfiguration,
- CourseEventBadgesConfiguration
-)
-
-admin.site.register(CourseCompleteImageConfiguration)
-admin.site.register(BadgeClass)
-admin.site.register(BadgeAssertion)
-# Use the standard Configuration Model Admin handler for this model.
-admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin)
diff --git a/lms/djangoapps/badges/api/__init__.py b/lms/djangoapps/badges/api/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py
deleted file mode 100644
index 2bcdd740eb..0000000000
--- a/lms/djangoapps/badges/api/serializers.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""
-Serializers for Badges
-"""
-
-
-from rest_framework import serializers
-
-from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass
-
-
-class BadgeClassSerializer(serializers.ModelSerializer):
- """
- Serializer for BadgeClass model.
- """
- image_url = serializers.ImageField(source='image')
-
- class Meta:
- model = BadgeClass
- fields = ('slug', 'issuing_component', 'display_name', 'course_id', 'description', 'criteria', 'image_url')
-
-
-class BadgeAssertionSerializer(serializers.ModelSerializer):
- """
- Serializer for the BadgeAssertion model.
- """
- badge_class = BadgeClassSerializer(read_only=True)
-
- class Meta:
- model = BadgeAssertion
- fields = ('badge_class', 'image_url', 'assertion_url', 'created')
diff --git a/lms/djangoapps/badges/api/tests.py b/lms/djangoapps/badges/api/tests.py
deleted file mode 100644
index e0031c29de..0000000000
--- a/lms/djangoapps/badges/api/tests.py
+++ /dev/null
@@ -1,218 +0,0 @@
-"""
-Tests for the badges API views.
-"""
-
-
-from ddt import data, ddt, unpack
-from django.conf import settings
-from django.test.utils import override_settings
-
-from common.djangoapps.student.tests.factories import UserFactory
-from common.djangoapps.util.testing import UrlResetMixin
-from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory
-from openedx.core.lib.api.test_utils import ApiTestCase
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
-
-FEATURES_WITH_BADGES_ENABLED = settings.FEATURES.copy()
-FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True
-
-
-@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
-class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase):
- """
- Mixin for badge API tests.
- """
-
- def setUp(self):
- super().setUp()
- self.course = CourseFactory.create()
- self.user = UserFactory.create()
- # Password defined by factory.
- self.client.login(username=self.user.username, password='test')
-
- def url(self):
- """
- Return the URL to look up the current user's assertions.
- """
- return f'/api/badges/v1/assertions/user/{self.user.username}/'
-
- def check_class_structure(self, badge_class, json_class):
- """
- Check a JSON response against a known badge class.
- """
- assert badge_class.issuing_component == json_class['issuing_component']
- assert badge_class.slug == json_class['slug']
- assert badge_class.image.url in json_class['image_url']
- assert badge_class.description == json_class['description']
- assert badge_class.criteria == json_class['criteria']
- assert (badge_class.course_id and str(badge_class.course_id)) == json_class['course_id']
-
- def check_assertion_structure(self, assertion, json_assertion):
- """
- Check a JSON response against a known assertion object.
- """
- assert assertion.image_url == json_assertion['image_url']
- assert assertion.assertion_url == json_assertion['assertion_url']
- self.check_class_structure(assertion.badge_class, json_assertion['badge_class'])
-
- def get_course_id(self, wildcard, badge_class):
- """
- Used for tests which may need to test for a course_id or a wildcard.
- """
- if wildcard:
- return '*'
- else:
- return str(badge_class.course_id)
-
- def create_badge_class(self, check_course, **kwargs):
- """
- Create a badge class, using a course id if it's relevant to the URL pattern.
- """
- if check_course:
- return RandomBadgeClassFactory.create(course_id=self.course.location.course_key, **kwargs)
- return RandomBadgeClassFactory.create(**kwargs)
-
- def get_qs_args(self, check_course, wildcard, badge_class):
- """
- Get a dictionary to be serialized into querystring params based on class settings.
- """
- qs_args = {
- 'issuing_component': badge_class.issuing_component,
- 'slug': badge_class.slug,
- }
- if check_course:
- qs_args['course_id'] = self.get_course_id(wildcard, badge_class)
- return qs_args
-
-
-class TestUserBadgeAssertions(UserAssertionTestCase):
- """
- Test the general badge assertions retrieval view.
- """
-
- def test_get_assertions(self):
- """
- Verify we can get all of a user's badge assertions.
- """
- for dummy in range(3):
- BadgeAssertionFactory(user=self.user)
- # Add in a course scoped badge-- these should not be excluded from the full listing.
- BadgeAssertionFactory(user=self.user, badge_class=BadgeClassFactory(course_id=self.course.location.course_key))
- # Should not be included.
- for dummy in range(3):
- self.create_badge_class(False)
- response = self.get_json(self.url())
- assert len(response['results']) == 4
-
- def test_assertion_structure(self):
- badge_class = self.create_badge_class(False)
- assertion = BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
- response = self.get_json(self.url())
- self.check_assertion_structure(assertion, response['results'][0])
-
-
-class TestUserCourseBadgeAssertions(UserAssertionTestCase):
- """
- Test the Badge Assertions view with the course_id filter.
- """
-
- def test_get_assertions(self):
- """
- Verify we can get assertions via the course_id and username.
- """
- course_key = self.course.location.course_key
- badge_class = BadgeClassFactory.create(course_id=course_key)
- for dummy in range(3):
- BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
- # Should not be included, as they don't share the target badge class.
- for dummy in range(3):
- BadgeAssertionFactory.create(user=self.user)
- # Also should not be included, as they don't share the same user.
- for dummy in range(6):
- BadgeAssertionFactory.create(badge_class=badge_class)
- response = self.get_json(self.url(), data={'course_id': str(course_key)})
- assert len(response['results']) == 3
- unused_course = CourseFactory.create()
- response = self.get_json(self.url(), data={'course_id': str(unused_course.location.course_key)})
- assert len(response['results']) == 0
-
- def test_assertion_structure(self):
- """
- Verify the badge assertion structure is as expected when a course is involved.
- """
- course_key = self.course.location.course_key
- badge_class = BadgeClassFactory.create(course_id=course_key)
- assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
- response = self.get_json(self.url())
- self.check_assertion_structure(assertion, response['results'][0])
-
-
-@ddt
-class TestUserBadgeAssertionsByClass(UserAssertionTestCase):
- """
- Test the Badge Assertions view with the badge class filter.
- """
-
- @unpack
- @data((False, False), (True, False), (True, True))
- def test_get_assertions(self, check_course, wildcard):
- """
- Verify we can get assertions via the badge class and username.
- """
- badge_class = self.create_badge_class(check_course)
- for dummy in range(3):
- BadgeAssertionFactory.create(user=self.user, badge_class=badge_class)
- if badge_class.course_id:
- # Also create a version of this badge under a different course.
- alt_class = BadgeClassFactory.create(
- slug=badge_class.slug, issuing_component=badge_class.issuing_component,
- course_id=CourseFactory.create().location.course_key
- )
- BadgeAssertionFactory.create(user=self.user, badge_class=alt_class)
- # Same badge class, but different user. Should not show up in the list.
- for dummy in range(5):
- BadgeAssertionFactory.create(badge_class=badge_class)
- # Different badge class AND different user. Certainly shouldn't show up in the list!
- for dummy in range(6):
- BadgeAssertionFactory.create()
-
- response = self.get_json(
- self.url(),
- data=self.get_qs_args(check_course, wildcard, badge_class),
- )
- if wildcard:
- expected_length = 4
- else:
- expected_length = 3
- assert len(response['results']) == expected_length
- unused_class = self.create_badge_class(check_course, slug='unused_slug', issuing_component='unused_component')
-
- response = self.get_json(
- self.url(),
- data=self.get_qs_args(check_course, wildcard, unused_class),
- )
- assert len(response['results']) == 0
-
- def check_badge_class_assertion(self, check_course, wildcard, badge_class):
- """
- Given a badge class, create an assertion for the current user and fetch it, checking the structure.
- """
- assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user)
- response = self.get_json(
- self.url(),
- data=self.get_qs_args(check_course, wildcard, badge_class),
- )
- self.check_assertion_structure(assertion, response['results'][0])
-
- @unpack
- @data((False, False), (True, False), (True, True))
- def test_assertion_structure(self, check_course, wildcard):
- self.check_badge_class_assertion(check_course, wildcard, self.create_badge_class(check_course))
-
- @unpack
- @data((False, False), (True, False), (True, True))
- def test_empty_issuing_component(self, check_course, wildcard):
- self.check_badge_class_assertion(
- check_course, wildcard, self.create_badge_class(check_course, issuing_component='')
- )
diff --git a/lms/djangoapps/badges/api/urls.py b/lms/djangoapps/badges/api/urls.py
deleted file mode 100644
index 95129fafcd..0000000000
--- a/lms/djangoapps/badges/api/urls.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""
-URLs for badges API
-"""
-
-
-from django.conf import settings
-from django.urls import re_path
-
-from .views import UserBadgeAssertions
-
-urlpatterns = [
- re_path('^assertions/user/' + settings.USERNAME_PATTERN + '/$',
- UserBadgeAssertions.as_view(), name='user_assertions'),
-]
diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py
deleted file mode 100644
index b5b5b9cac4..0000000000
--- a/lms/djangoapps/badges/api/views.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""
-API views for badges
-"""
-
-
-from edx_rest_framework_extensions.auth.session.authentication import \
- SessionAuthenticationAllowInactiveUser
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.django.models import CourseKeyField
-from opaque_keys.edx.keys import CourseKey
-from rest_framework import generics
-from rest_framework.exceptions import APIException
-
-from lms.djangoapps.badges.models import BadgeAssertion
-from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory
-from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
-
-from .serializers import BadgeAssertionSerializer
-
-
-class InvalidCourseKeyError(APIException):
- """
- Raised the course key given isn't valid.
- """
- status_code = 400
- default_detail = "The course key provided was invalid."
-
-
-class UserBadgeAssertions(generics.ListAPIView):
- """
- **Use Cases**
-
- Request a list of assertions for a user, optionally constrained to a course.
-
- **Example Requests**
-
- GET /api/badges/v1/assertions/user/{username}/
-
- **Response Values**
-
- Body comprised of a list of objects with the following fields:
-
- * badge_class: The badge class the assertion was awarded for. Represented as an object
- with the following fields:
- * slug: The identifier for the badge class
- * issuing_component: The software component responsible for issuing this badge.
- * display_name: The display name of the badge.
- * course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course.
- * description: A description of the award and its significance.
- * criteria: A description of what is needed to obtain this award.
- * image_url: A URL to the icon image used to represent this award.
- * image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award
- in its headers.
- * assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools
- and software.
-
- **Params**
-
- * slug (optional): The identifier for a particular badge class to filter by.
- * issuing_component (optional): The issuing component for a particular badge class to filter by
- (requires slug to have been specified, or this will be ignored.) If slug is provided and this is not,
- assumes the issuing_component should be empty.
- * course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is
- provided, and this field is not specified, assumes that the target badge has an empty course_id field.
- '*' may be used to get all badges with the specified slug, issuing_component combination across all courses.
-
- **Returns**
-
- * 200 on success, with a list of Badge Assertion objects.
- * 403 if a user who does not have permission to masquerade as
- another user specifies a username other than their own.
- * 404 if the specified user does not exist
-
- {
- "count": 7,
- "previous": null,
- "num_pages": 1,
- "results": [
- {
- "badge_class": {
- "slug": "special_award",
- "issuing_component": "openedx__course",
- "display_name": "Very Special Award",
- "course_id": "course-v1:edX+DemoX+Demo_Course",
- "description": "Awarded for people who did something incredibly special",
- "criteria": "Do something incredibly special.",
- "image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png"
- },
- "image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png",
- "assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6"
- },
- ...
- ]
- }
- """
- serializer_class = BadgeAssertionSerializer
- authentication_classes = (
- BearerAuthenticationAllowInactiveUser,
- SessionAuthenticationAllowInactiveUser
- )
- permission_classes = (is_field_shared_factory("accomplishments_shared"),)
-
- def filter_queryset(self, queryset):
- """
- Return most recent to least recent badge.
- """
- return queryset.order_by('-created')
-
- def get_queryset(self):
- """
- Get all badges for the username specified.
- """
- queryset = BadgeAssertion.objects.filter(user__username=self.kwargs['username'])
- provided_course_id = self.request.query_params.get('course_id')
- if provided_course_id == '*':
- # We might want to get all the matching course scoped badges to see how many courses
- # a user managed to get a specific award on.
- course_id = None
- elif provided_course_id:
- try:
- course_id = CourseKey.from_string(provided_course_id)
- except InvalidKeyError:
- raise InvalidCourseKeyError # lint-amnesty, pylint: disable=raise-missing-from
- elif 'slug' not in self.request.query_params:
- # Need to get all badges for the user.
- course_id = None
- else:
- # Django won't let us use 'None' for querying a ForeignKey field. We have to use this special
- # 'Empty' value to indicate we're looking only for badges without a course key set.
- course_id = CourseKeyField.Empty
-
- if course_id is not None:
- queryset = queryset.filter(badge_class__course_id=course_id)
- if self.request.query_params.get('slug'):
- queryset = queryset.filter(
- badge_class__slug=self.request.query_params['slug'],
- badge_class__issuing_component=self.request.query_params.get('issuing_component', '')
- )
- return queryset
diff --git a/lms/djangoapps/badges/apps.py b/lms/djangoapps/badges/apps.py
index 4380a609b2..778c1b5790 100644
--- a/lms/djangoapps/badges/apps.py
+++ b/lms/djangoapps/badges/apps.py
@@ -13,9 +13,3 @@ class BadgesConfig(AppConfig):
Application Configuration for Badges.
"""
name = 'lms.djangoapps.badges'
-
- def ready(self):
- """
- Connect signal handlers.
- """
- from . import handlers # pylint: disable=unused-import
diff --git a/lms/djangoapps/badges/backends/__init__.py b/lms/djangoapps/badges/backends/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/badges/backends/badgr.py b/lms/djangoapps/badges/backends/badgr.py
deleted file mode 100644
index 7569c41a2a..0000000000
--- a/lms/djangoapps/badges/backends/badgr.py
+++ /dev/null
@@ -1,324 +0,0 @@
-"""
-Badge Awarding backend for Badgr-Server.
-"""
-
-
-import base64
-import datetime
-import json
-import hashlib
-import logging
-import mimetypes
-
-import requests
-from cryptography.fernet import Fernet
-from django.conf import settings
-from django.core.exceptions import ImproperlyConfigured
-from eventtracking import tracker
-from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
-from requests.packages.urllib3.exceptions import HTTPError # lint-amnesty, pylint: disable=import-error
-
-from edx_django_utils.cache import TieredCache
-
-from lms.djangoapps.badges.backends.base import BadgeBackend
-from lms.djangoapps.badges.models import BadgeAssertion
-
-MAX_SLUG_LENGTH = 255
-LOGGER = logging.getLogger(__name__)
-
-
-class BadgrBackend(BadgeBackend):
- """
- Backend for Badgr-Server by Concentric Sky. http://info.badgr.io/
- """
- badges = []
-
- def __init__(self):
- super().__init__()
- if None in (settings.BADGR_USERNAME,
- settings.BADGR_PASSWORD,
- settings.BADGR_TOKENS_CACHE_KEY,
- settings.BADGR_ISSUER_SLUG,
- settings.BADGR_BASE_URL):
- error_msg = (
- "One or more of the required settings are not defined. "
- "Required settings: BADGR_USERNAME, BADGR_PASSWORD, "
- "BADGR_TOKENS_CACHE_KEY, BADGR_ISSUER_SLUG, BADGR_BASE_URL.")
- LOGGER.error(error_msg)
- raise ImproperlyConfigured(error_msg)
-
- @lazy
- def _base_url(self):
- """
- Base URL for API requests that contain the issuer slug.
- """
- return f"{settings.BADGR_BASE_URL}/v2/issuers/{settings.BADGR_ISSUER_SLUG}"
-
- @lazy
- def _badge_create_url(self):
- """
- URL for generating a new Badge specification
- """
- return f"{self._base_url}/badgeclasses"
-
- def _badge_url(self, slug):
- """
- Get the URL for a course's badge in a given mode.
- """
- return f"{settings.BADGR_BASE_URL}/v2/badgeclasses/{slug}"
-
- def _assertion_url(self, slug):
- """
- URL for generating a new assertion.
- """
- return f"{self._badge_url(slug)}/assertions"
-
- def _slugify(self, badge_class):
- """
- Get a compatible badge slug from the specification.
- """
- slug = badge_class.issuing_component + badge_class.slug
- if badge_class.issuing_component and badge_class.course_id:
- # Make this unique to the course, and down to 64 characters.
- # We don't do this to badges without issuing_component set for backwards compatibility.
- slug = hashlib.sha256((slug + str(badge_class.course_id)).encode('utf-8')).hexdigest()
- if len(slug) > MAX_SLUG_LENGTH:
- # Will be 64 characters.
- slug = hashlib.sha256(slug).hexdigest()
- return slug
-
- def _log_if_raised(self, response, data):
- """
- Log server response if there was an error.
- """
- try:
- response.raise_for_status()
- except HTTPError:
- LOGGER.error(
- "Encountered an error when contacting the Badgr-Server. Request sent to %r with headers %r.\n"
- "and data values %r\n"
- "Response status was %s.\n%s",
- response.request.url, response.request.headers,
- data,
- response.status_code, response.content
- )
- raise
-
- def _create_badge(self, badge_class):
- """
- Create the badge class on Badgr.
- """
- image = badge_class.image
- # We don't want to bother validating the file any further than making sure we can detect its MIME type,
- # for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it.
- content_type, __ = mimetypes.guess_type(image.name)
- if not content_type:
- raise ValueError(
- "Could not determine content-type of image! Make sure it is a properly named .png file. "
- "Filename was: {}".format(image.name)
- )
- with open(image.path, 'rb') as image_file:
- files = {'image': (image.name, image_file, content_type)}
- data = {
- 'name': badge_class.display_name,
- 'criteriaUrl': badge_class.criteria,
- 'description': badge_class.description,
- }
- result = requests.post(
- self._badge_create_url, headers=self._get_headers(),
- data=data, files=files, timeout=settings.BADGR_TIMEOUT)
- self._log_if_raised(result, data)
- try:
- result_json = result.json()
- badgr_badge_class = result_json['result'][0]
- badgr_server_slug = badgr_badge_class.get('entityId')
- badge_class.badgr_server_slug = badgr_server_slug
- badge_class.save()
- except Exception as excep: # pylint: disable=broad-except
- LOGGER.error(
- 'Error on saving Badgr Server Slug of badge_class slug '
- '"{0}" with response json "{1}" : {2}'.format(
- badge_class.slug, result.json(), excep))
-
- def _send_assertion_created_event(self, user, assertion):
- """
- Send an analytics event to record the creation of a badge assertion.
- """
- tracker.emit(
- 'edx.badge.assertion.created', {
- 'user_id': user.id,
- 'badge_slug': assertion.badge_class.slug,
- 'badge_badgr_server_slug': assertion.badge_class.badgr_server_slug,
- 'badge_name': assertion.badge_class.display_name,
- 'issuing_component': assertion.badge_class.issuing_component,
- 'course_id': str(assertion.badge_class.course_id),
- 'enrollment_mode': assertion.badge_class.mode,
- 'assertion_id': assertion.id,
- 'assertion_image_url': assertion.image_url,
- 'assertion_json_url': assertion.assertion_url,
- 'issuer': assertion.data.get('issuer'),
- }
- )
-
- def _create_assertion(self, badge_class, user, evidence_url):
- """
- Register an assertion with the Badgr server for a particular user for a specific class.
- """
- data = {
- "recipient": {
- "identity": user.email,
- "type": "email"
- },
- "evidence": [
- {
- "url": evidence_url
- }
- ],
- "notify": settings.BADGR_ENABLE_NOTIFICATIONS,
- }
- response = requests.post(
- self._assertion_url(badge_class.badgr_server_slug),
- headers=self._get_headers(),
- json=data,
- timeout=settings.BADGR_TIMEOUT
- )
- self._log_if_raised(response, data)
- assertion, __ = BadgeAssertion.objects.get_or_create(user=user, badge_class=badge_class)
- try:
- response_json = response.json()
- assertion.data = response_json['result'][0]
- assertion.image_url = assertion.data['image']
- assertion.assertion_url = assertion.data['openBadgeId']
- assertion.backend = 'BadgrBackend'
- assertion.save()
- self._send_assertion_created_event(user, assertion)
- return assertion
-
- except Exception as exc: # pylint: disable=broad-except
- LOGGER.error(
- 'Error saving BadgeAssertion for user: "{0}" '
- 'with response from server: {1};'
- 'Encountered exception: {2}'.format(
- user.email, response.text, exc))
-
- @staticmethod
- def _fernet_setup():
- """
- Set up the Fernet class for encrypting/decrypting tokens.
- Fernet keys must always be URL-safe base64 encoded 32-byte binary
- strings. Use the SECRET_KEY for creating the encryption key.
- """
- fernet_key = base64.urlsafe_b64encode(
- settings.SECRET_KEY.ljust(64).encode('utf-8')[:32]
- )
- return Fernet(fernet_key)
-
- def _encrypt_token(self, token):
- """
- Encrypt a token
- """
- fernet = self._fernet_setup()
- return fernet.encrypt(token.encode('utf-8'))
-
- def _decrypt_token(self, token):
- """
- Decrypt a token
- """
- fernet = self._fernet_setup()
- return fernet.decrypt(token).decode()
-
- def _get_and_cache_oauth_tokens(self, refresh_token=None):
- """
- Get or renew OAuth tokens. If a refresh_token is provided,
- use it to renew tokens, otherwise create new ones.
- Once tokens are created/renewed, encrypt the values and cache them.
- """
- data = {
- 'username': settings.BADGR_USERNAME,
- 'password': settings.BADGR_PASSWORD,
- }
- if refresh_token:
- data = {
- 'grant_type': 'refresh_token',
- 'refresh_token': refresh_token
- }
-
- oauth_url = "{}/o/token".format(settings.BADGR_BASE_URL)
-
- response = requests.post(
- oauth_url, data=data, timeout=settings.BADGR_TIMEOUT
- )
- self._log_if_raised(response, data)
- try:
- data = response.json()
- result = {
- 'access_token': self._encrypt_token(data['access_token']),
- 'refresh_token': self._encrypt_token(data['refresh_token']),
- 'expires_at': datetime.datetime.utcnow() + datetime.timedelta(
- seconds=data['expires_in'])
- }
- # The refresh_token is long-lived, we want to be able to retrieve
- # it from cache as long as possible.
- # Set the cache timeout to None so the cache key never expires
- # (https://docs.djangoproject.com/en/2.2/topics/cache/#cache-arguments)
- TieredCache.set_all_tiers(
- settings.BADGR_TOKENS_CACHE_KEY, result, None)
- return result
- except (KeyError, json.decoder.JSONDecodeError) as json_error:
- raise requests.RequestException(response=response) from json_error
-
- def _get_access_token(self):
- """
- Get an access token from cache if one is present and valid. If a
- token is cached but expired, renew it. If all fails or a token has
- not yet been cached, create a new one.
- """
- tokens = {}
- cached_response = TieredCache.get_cached_response(
- settings.BADGR_TOKENS_CACHE_KEY)
- if cached_response.is_found:
- cached_tokens = cached_response.value
- # add a 5 seconds buffer to the cutoff timestamp to make sure
- # the token will not expire while in use
- expiry_cutoff = (
- datetime.datetime.utcnow() + datetime.timedelta(seconds=5))
- if cached_tokens.get('expires_at') > expiry_cutoff:
- tokens = cached_tokens
- else:
- # renew the tokens with the cached `refresh_token`
- refresh_token = self._decrypt_token(cached_tokens.get(
- 'refresh_token'))
- tokens = self._get_and_cache_oauth_tokens(
- refresh_token=refresh_token)
-
- # if no tokens are cached or something went wrong with
- # retreiving/renewing them, go and create new tokens
- if not tokens:
- tokens = self._get_and_cache_oauth_tokens()
- return self._decrypt_token(tokens.get('access_token'))
-
- def _get_headers(self):
- """
- Headers to send along with the request-- used for authentication.
- """
- access_token = self._get_access_token()
- return {'Authorization': 'Bearer {}'.format(access_token)}
-
- def _ensure_badge_created(self, badge_class):
- """
- Verify a badge has been created for this badge class, and create it if not.
- """
- slug = badge_class.badgr_server_slug
- if slug in BadgrBackend.badges:
- return
- response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT)
- if response.status_code != 200:
- self._create_badge(badge_class)
- BadgrBackend.badges.append(slug)
-
- def award(self, badge_class, user, evidence_url=None):
- """
- Make sure the badge class has been created on the backend, and then award the badge class to the user.
- """
- self._ensure_badge_created(badge_class)
- return self._create_assertion(badge_class, user, evidence_url)
diff --git a/lms/djangoapps/badges/backends/base.py b/lms/djangoapps/badges/backends/base.py
deleted file mode 100644
index 868ef0df74..0000000000
--- a/lms/djangoapps/badges/backends/base.py
+++ /dev/null
@@ -1,18 +0,0 @@
-"""
-Base class for badge backends.
-"""
-
-
-from abc import ABCMeta, abstractmethod
-
-
-class BadgeBackend(metaclass=ABCMeta):
- """
- Defines the interface for badging backends.
- """
-
- @abstractmethod
- def award(self, badge_class, user, evidence_url=None):
- """
- Create a badge assertion for the user using this backend.
- """
diff --git a/lms/djangoapps/badges/backends/tests/__init__.py b/lms/djangoapps/badges/backends/tests/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/badges/backends/tests/dummy_backend.py b/lms/djangoapps/badges/backends/tests/dummy_backend.py
deleted file mode 100644
index 042ffe9b24..0000000000
--- a/lms/djangoapps/badges/backends/tests/dummy_backend.py
+++ /dev/null
@@ -1,15 +0,0 @@
-"""
-Dummy backend, for use in testing.
-"""
-
-
-from lms.djangoapps.badges.backends.base import BadgeBackend
-from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory
-
-
-class DummyBackend(BadgeBackend):
- """
- Dummy backend that creates assertions without contacting any real-world backend.
- """
- def award(self, badge_class, user, evidence_url=None):
- return BadgeAssertionFactory(badge_class=badge_class, user=user)
diff --git a/lms/djangoapps/badges/backends/tests/test_badgr_backend.py b/lms/djangoapps/badges/backends/tests/test_badgr_backend.py
deleted file mode 100644
index 7e9f2d367a..0000000000
--- a/lms/djangoapps/badges/backends/tests/test_badgr_backend.py
+++ /dev/null
@@ -1,301 +0,0 @@
-"""
-Tests for BadgrBackend
-"""
-
-import datetime
-from unittest.mock import Mock, call, patch
-
-import json
-import ddt
-import httpretty
-from django.test.utils import override_settings
-from lazy.lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
-
-from edx_django_utils.cache import TieredCache
-from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
-from common.djangoapps.track.tests import FROZEN_TIME, EventTrackingTestCase
-from lms.djangoapps.badges.backends.badgr import BadgrBackend
-from lms.djangoapps.badges.models import BadgeAssertion
-from lms.djangoapps.badges.tests.factories import BadgeClassFactory
-from openedx.core.lib.tests.assertions.events import assert_event_matches
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
-
-BADGR_SETTINGS = {
- 'BADGR_BASE_URL': 'https://example.com',
- 'BADGR_ISSUER_SLUG': 'test-issuer',
- 'BADGR_USERNAME': 'example@example.com',
- 'BADGR_PASSWORD': 'password',
- 'BADGR_TOKENS_CACHE_KEY': 'badgr-test-cache-key'
-}
-
-# Should be the hashed result of test_slug as the slug, and test_component as the component
-EXAMPLE_SLUG = '9e915d55bb304a73d20c453531d3c27f81574218413c23903823d20d11b587ae'
-BADGR_SERVER_SLUG = 'test_badgr_server_slug'
-
-
-# pylint: disable=protected-access
-@ddt.ddt
-@override_settings(**BADGR_SETTINGS)
-@httpretty.activate
-class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase):
- """
- Tests the BadgeHandler object
- """
-
- def setUp(self):
- """
- Create a course and user to test with.
- """
- super().setUp()
- # Need key to be deterministic to test slugs.
- self.course = CourseFactory.create(
- org='edX', course='course_test', run='test_run', display_name='Badged',
- start=datetime.datetime(year=2015, month=5, day=19),
- end=datetime.datetime(year=2015, month=5, day=20)
- )
- self.user = UserFactory.create(email='example@example.com')
- CourseEnrollmentFactory.create(user=self.user, course_id=self.course.location.course_key, mode='honor')
- # Need to empty this on each run.
- BadgrBackend.badges = []
- self.badge_class = BadgeClassFactory.create(course_id=self.course.location.course_key)
- self.legacy_badge_class = BadgeClassFactory.create(
- course_id=self.course.location.course_key, issuing_component=''
- )
- self.no_course_badge_class = BadgeClassFactory.create()
- TieredCache.dangerous_clear_all_tiers()
- httpretty.httpretty.reset()
-
- @lazy
- def handler(self):
- """
- Lazily loads a BadgeHandler object for the current course. Can't do this on setUp because the settings
- overrides aren't in place.
- """
- return BadgrBackend()
-
- def _mock_badgr_tokens_api(self, result):
- assert httpretty.is_enabled()
- responses = [httpretty.Response(body=json.dumps(result),
- content_type='application/json')]
- httpretty.register_uri(httpretty.POST,
- 'https://example.com/o/token',
- responses=responses)
-
- def test_urls(self):
- """
- Make sure the handler generates the correct URLs for different API tasks.
- """
- assert self.handler._base_url == 'https://example.com/v2/issuers/test-issuer'
- # lint-amnesty, pylint: disable=no-member
- assert self.handler._badge_create_url == 'https://example.com/v2/issuers/test-issuer/badgeclasses'
- # lint-amnesty, pylint: disable=no-member
- assert self.handler._badge_url('test_slug_here') ==\
- 'https://example.com/v2/badgeclasses/test_slug_here'
- assert self.handler._assertion_url('another_test_slug') ==\
- 'https://example.com/v2/badgeclasses/another_test_slug/assertions'
-
- def check_headers(self, headers):
- """
- Verify the a headers dict from a requests call matches the proper auth info.
- """
- assert headers == {'Authorization': 'Bearer 12345'}
-
- def test_get_headers(self):
- """
- Check to make sure the handler generates appropriate HTTP headers.
- """
- self.handler._get_access_token = Mock(return_value='12345')
- self.check_headers(self.handler._get_headers()) # lint-amnesty, pylint: disable=no-member
-
- @patch('requests.post')
- def test_create_badge(self, post):
- """
- Verify badge spec creation works.
- """
- self.handler._get_access_token = Mock(return_value='12345')
- with self.allow_transaction_exception():
- self.handler._create_badge(self.badge_class)
- args, kwargs = post.call_args
- assert args[0] == 'https://example.com/v2/issuers/test-issuer/badgeclasses'
- assert kwargs['files']['image'][0] == self.badge_class.image.name
- assert kwargs['files']['image'][2] == 'image/png'
- self.check_headers(kwargs['headers'])
- assert kwargs['data'] ==\
- {'name': 'Test Badge',
- 'criteriaUrl': 'https://example.com/syllabus',
- 'description': "Yay! It's a test badge."}
-
- def test_ensure_badge_created_cache(self):
- """
- Make sure ensure_badge_created doesn't call create_badge if we know the badge is already there.
- """
- BadgrBackend.badges.append(BADGR_SERVER_SLUG)
- self.handler._create_badge = Mock()
- self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
- assert not self.handler._create_badge.called
-
- @ddt.unpack
- @ddt.data(
- ('badge_class', EXAMPLE_SLUG),
- ('legacy_badge_class', 'test_slug'),
- ('no_course_badge_class', 'test_componenttest_slug')
- )
- def test_slugs(self, badge_class_type, slug):
- assert self.handler._slugify(getattr(self, badge_class_type)) == slug
- # lint-amnesty, pylint: disable=no-member
-
- @patch('requests.get')
- def test_ensure_badge_created_checks(self, get):
- response = Mock()
- response.status_code = 200
- get.return_value = response
- assert 'test_componenttest_slug' not in BadgrBackend.badges
- self.handler._get_access_token = Mock(return_value='12345')
- self.handler._create_badge = Mock()
- self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
- assert get.called
- args, kwargs = get.call_args
- assert args[0] == (
- 'https://example.com/v2/badgeclasses/' +
- BADGR_SERVER_SLUG)
- self.check_headers(kwargs['headers'])
- assert BADGR_SERVER_SLUG in BadgrBackend.badges
- assert not self.handler._create_badge.called
-
- @patch('requests.get')
- def test_ensure_badge_created_creates(self, get):
- response = Mock()
- response.status_code = 404
- get.return_value = response
- assert BADGR_SERVER_SLUG not in BadgrBackend.badges
- self.handler._get_access_token = Mock(return_value='12345')
- self.handler._create_badge = Mock()
- self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member
- assert self.handler._create_badge.called
- assert self.handler._create_badge.call_args == call(self.badge_class)
- assert BADGR_SERVER_SLUG in BadgrBackend.badges
-
- @patch('requests.post')
- def test_badge_creation_event(self, post):
- result = {
- 'result': [{
- 'openBadgeId': 'http://www.example.com/example',
- 'image': 'http://www.example.com/example.png',
- 'issuer': 'https://example.com/v2/issuers/test-issuer'
- }]
- }
- response = Mock()
- response.json.return_value = result
- post.return_value = response
- self.recreate_tracker()
- self.handler._get_access_token = Mock(return_value='12345')
- self.handler._create_assertion(self.badge_class, self.user, 'https://example.com/irrefutable_proof') # lint-amnesty, pylint: disable=no-member
- args, kwargs = post.call_args
- assert args[0] == ((
- 'https://example.com/v2/badgeclasses/' +
- BADGR_SERVER_SLUG) +
- '/assertions')
- self.check_headers(kwargs['headers'])
- assertion = BadgeAssertion.objects.get(user=self.user, badge_class__course_id=self.course.location.course_key)
- assert assertion.data == result['result'][0]
- assert assertion.image_url == 'http://www.example.com/example.png'
- assert assertion.assertion_url == 'http://www.example.com/example'
- assert kwargs['json'] == {"recipient": {"identity": 'example@example.com', "type": "email"},
- "evidence": [{"url": 'https://example.com/irrefutable_proof'}],
- "notify": False}
- assert_event_matches({
- 'name': 'edx.badge.assertion.created',
- 'data': {
- 'user_id': self.user.id,
- 'course_id': str(self.course.location.course_key),
- 'enrollment_mode': 'honor',
- 'assertion_id': assertion.id,
- 'badge_name': 'Test Badge',
- 'badge_slug': 'test_slug',
- 'badge_badgr_server_slug': BADGR_SERVER_SLUG,
- 'issuing_component': 'test_component',
- 'assertion_image_url': 'http://www.example.com/example.png',
- 'assertion_json_url': 'http://www.example.com/example',
- 'issuer': 'https://example.com/v2/issuers/test-issuer',
- },
- 'context': {},
- 'timestamp': FROZEN_TIME
- }, self.get_event())
-
- def test_get_new_tokens(self):
- result = {
- 'access_token': '12345',
- 'refresh_token': '67890',
- 'expires_in': 86400,
- }
- self._mock_badgr_tokens_api(result)
- self.handler._get_and_cache_oauth_tokens()
- assert 'o/token' in httpretty.httpretty.last_request.path
- assert httpretty.httpretty.last_request.parsed_body == {
- 'username': ['example@example.com'],
- 'password': ['password']}
-
- def test_renew_tokens(self):
- result = {
- 'access_token': '12345',
- 'refresh_token': '67890',
- 'expires_in': 86400,
- }
- self._mock_badgr_tokens_api(result)
- self.handler._get_and_cache_oauth_tokens(refresh_token='67890')
- assert 'o/token' in httpretty.httpretty.last_request.path
- assert httpretty.httpretty.last_request.parsed_body == {
- 'grant_type': ['refresh_token'],
- 'refresh_token': ['67890']}
-
- def test_get_access_token_from_cache_valid(self):
- encrypted_access_token = self.handler._encrypt_token('12345')
- encrypted_refresh_token = self.handler._encrypt_token('67890')
- tokens = {
- 'access_token': encrypted_access_token,
- 'refresh_token': encrypted_refresh_token,
- 'expires_at': datetime.datetime.utcnow() + datetime.timedelta(seconds=20)
- }
- TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None)
-
- access_token = self.handler._get_access_token()
- assert access_token == self.handler._decrypt_token(
- tokens.get('access_token'))
-
- def test_get_access_token_from_cache_expired(self):
- encrypted_access_token = self.handler._encrypt_token('12345')
- encrypted_refresh_token = self.handler._encrypt_token('67890')
- tokens = {
- 'access_token': encrypted_access_token,
- 'refresh_token': encrypted_refresh_token,
- 'expires_at': datetime.datetime.utcnow()
- }
- TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None)
- result = {
- 'access_token': '12345',
- 'refresh_token': '67890',
- 'expires_in': 86400,
- }
- self._mock_badgr_tokens_api(result)
- access_token = self.handler._get_access_token()
- assert access_token == result.get('access_token')
- assert 'o/token' in httpretty.httpretty.last_request.path
- assert httpretty.httpretty.last_request.parsed_body == {
- 'grant_type': ['refresh_token'],
- 'refresh_token': [self.handler._decrypt_token(
- tokens.get('refresh_token'))]}
-
- def test_get_access_token_from_cache_none(self):
- result = {
- 'access_token': '12345',
- 'refresh_token': '67890',
- 'expires_in': 86400,
- }
- self._mock_badgr_tokens_api(result)
- access_token = self.handler._get_access_token()
- assert access_token == result.get('access_token')
- assert 'o/token' in httpretty.httpretty.last_request.path
- assert httpretty.httpretty.last_request.parsed_body == {
- 'username': ['example@example.com'],
- 'password': ['password']}
diff --git a/lms/djangoapps/badges/events/course_complete.py b/lms/djangoapps/badges/events/course_complete.py
index 853465dfb8..654e3859ef 100644
--- a/lms/djangoapps/badges/events/course_complete.py
+++ b/lms/djangoapps/badges/events/course_complete.py
@@ -6,14 +6,9 @@ Helper functions for the course complete event that was originally included with
import hashlib
import logging
-from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
-from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass, CourseCompleteImageConfiguration
-from lms.djangoapps.badges.utils import requires_badges_enabled, site_prefix
-from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
-
LOGGER = logging.getLogger(__name__)
@@ -25,9 +20,7 @@ def course_slug(course_key, mode):
"""
Legacy: Not to be used as a model for constructing badge slugs. Included for compatibility with the original badge
type, awarded on course completion.
-
Slug ought to be deterministic and limited in size so it's not too big for Badgr.
-
Badgr's max slug length is 255.
"""
# Seven digits should be enough to realistically avoid collisions. That's what git services use.
@@ -65,65 +58,4 @@ def evidence_url(user_id, course_key):
Generates a URL to the user's Certificate HTML view, along with a GET variable that will signal the evidence visit
event.
"""
- course_id = str(course_key)
- # avoid circular import problems
- from lms.djangoapps.certificates.models import GeneratedCertificate
- cert = GeneratedCertificate.eligible_certificates.get(user__id=int(user_id), course_id=course_id)
- return site_prefix() + reverse(
- 'certificates:render_cert_by_uuid', kwargs={'certificate_uuid': cert.verify_uuid}) + '?evidence_visit=1'
-
-
-def criteria(course_key):
- """
- Constructs the 'criteria' URL from the course about page.
- """
- about_path = reverse('about_course', kwargs={'course_id': str(course_key)})
- return f'{site_prefix()}{about_path}'
-
-
-def get_completion_badge(course_id, user):
- """
- Given a course key and a user, find the user's enrollment mode
- and get the Course Completion badge.
- """
- from common.djangoapps.student.models import CourseEnrollment
- badge_classes = CourseEnrollment.objects.filter(
- user=user, course_id=course_id
- ).order_by('-is_active')
- if not badge_classes:
- return None
- mode = badge_classes[0].mode
- course = modulestore().get_course(course_id)
- if not course.issue_badges:
- return None
- return BadgeClass.get_badge_class(
- slug=course_slug(course_id, mode),
- issuing_component='',
- criteria=criteria(course_id),
- description=badge_description(course, mode),
- course_id=course_id,
- mode=mode,
- display_name=course.display_name,
- image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode)
- )
-
-
-@requires_badges_enabled
-def course_badge_check(user, course_key):
- """
- Takes a GeneratedCertificate instance, and checks to see if a badge exists for this course, creating
- it if not, should conditions be right.
- """
- if not modulestore().get_course(course_key).issue_badges:
- LOGGER.info("Course is not configured to issue badges.")
- return
- badge_class = get_completion_badge(course_key, user)
- if not badge_class:
- # We're not configured to make a badge for this course mode.
- return
- if BadgeAssertion.objects.filter(user=user, badge_class=badge_class):
- LOGGER.info("Completion badge already exists for this user on this course.")
- # Badge already exists. Skip.
- return
- evidence = evidence_url(user.id, course_key)
- badge_class.award(user, evidence_url=evidence)
+ return
diff --git a/lms/djangoapps/badges/events/course_meta.py b/lms/djangoapps/badges/events/course_meta.py
deleted file mode 100644
index bef7b8dd79..0000000000
--- a/lms/djangoapps/badges/events/course_meta.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-Events which have to do with a user doing something with more than one course, such
-as enrolling in a certain number, completing a certain number, or completing a specific set of courses.
-"""
-
-
-from lms.djangoapps.badges.models import BadgeClass, CourseEventBadgesConfiguration
-from lms.djangoapps.badges.utils import requires_badges_enabled
-
-
-def award_badge(config, count, user):
- """
- Given one of the configurations for enrollments or completions, award
- the appropriate badge if one is configured.
-
- config is a dictionary with integer keys and course keys as values.
- count is the key to retrieve from this dictionary.
- user is the user to award the badge to.
-
- Example config:
- {3: 'slug_for_badge_for_three_enrollments', 5: 'slug_for_badge_with_five_enrollments'}
- """
- slug = config.get(count)
- if not slug:
- return
- badge_class = BadgeClass.get_badge_class(
- slug=slug, issuing_component='openedx__course', create=False,
- )
- if not badge_class:
- return
- if not badge_class.get_for_user(user):
- badge_class.award(user)
-
-
-def award_enrollment_badge(user):
- """
- Awards badges based on the number of courses a user is enrolled in.
- """
- config = CourseEventBadgesConfiguration.current().enrolled_settings
- enrollments = user.courseenrollment_set.filter(is_active=True).count()
- award_badge(config, enrollments, user)
-
-
-@requires_badges_enabled
-def completion_check(user):
- """
- Awards badges based upon the number of courses a user has 'completed'.
- Courses are never truly complete, but they can be closed.
-
- For this reason we use checks on certificates to find out if a user has
- completed courses. This badge will not work if certificate generation isn't
- enabled and run.
- """
- from lms.djangoapps.certificates.data import CertificateStatuses
- config = CourseEventBadgesConfiguration.current().completed_settings
- certificates = user.generatedcertificate_set.filter(status__in=CertificateStatuses.PASSED_STATUSES).count()
- award_badge(config, certificates, user)
-
-
-@requires_badges_enabled
-def course_group_check(user, course_key):
- """
- Awards a badge if a user has completed every course in a defined set.
- """
- from lms.djangoapps.certificates.data import CertificateStatuses
- config = CourseEventBadgesConfiguration.current().course_group_settings
- awards = []
- for slug, keys in config.items():
- if course_key in keys:
- certs = user.generatedcertificate_set.filter(
- status__in=CertificateStatuses.PASSED_STATUSES,
- course_id__in=keys,
- )
- if len(certs) == len(keys):
- awards.append(slug)
-
- for slug in awards:
- badge_class = BadgeClass.get_badge_class(
- slug=slug, issuing_component='openedx__course', create=False,
- )
- if badge_class and not badge_class.get_for_user(user):
- badge_class.award(user)
diff --git a/lms/djangoapps/badges/events/tests/__init__.py b/lms/djangoapps/badges/events/tests/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/badges/events/tests/test_course_complete.py b/lms/djangoapps/badges/events/tests/test_course_complete.py
deleted file mode 100644
index 582415d9c1..0000000000
--- a/lms/djangoapps/badges/events/tests/test_course_complete.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Tests for the course completion helper functions.
-"""
-from datetime import datetime
-from uuid import uuid4
-
-from common.djangoapps.student.tests.factories import UserFactory
-from lms.djangoapps.badges.events import course_complete
-from lms.djangoapps.certificates.models import GeneratedCertificate
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
-
-
-class CourseCompleteTestCase(ModuleStoreTestCase):
- """
- Tests for the course completion helper functions.
- """
-
- def setUp(self):
- super().setUp()
- # Need key to be deterministic to test slugs.
- self.course = CourseFactory.create(
- org='edX', course='course_test', run='test_run', display_name='Badged',
- start=datetime(year=2015, month=5, day=19),
- end=datetime(year=2015, month=5, day=20)
- )
- self.course_key = self.course.location.course_key
-
- def test_slug(self):
- """
- Verify slug generation is working as expected. If this test fails, the algorithm has changed, and it will cause
- the handler to lose track of all badges it made in the past.
- """
- assert course_complete.course_slug(self.course_key, 'honor') ==\
- 'course-v1edxcourse_testtest_run_honor_2055051'
- assert course_complete.course_slug(self.course_key, 'verified') ==\
- 'course-v1edxcourse_testtest_run_verified_d550ad7'
-
- def test_dated_description(self):
- """
- Verify that a course with start/end dates contains a description with them.
- """
- assert course_complete.badge_description(self.course, 'honor') ==\
- 'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)'
-
- def test_self_paced_description(self):
- """
- Verify that a badge created for a course with no end date gets a different description.
- """
- self.course.end = None
- assert course_complete.badge_description(self.course, 'honor') == 'Completed the course "Badged" (honor)'
-
- def test_evidence_url(self):
- """
- Make sure the evidence URL points to the right place.
- """
- user = UserFactory.create()
- cert = GeneratedCertificate.eligible_certificates.create(
- user=user,
- course_id=self.course_key,
- download_uuid=uuid4(),
- grade="0.95",
- key='the_key',
- distinction=True,
- status='downloadable',
- mode='honor',
- name=user.profile.name,
- verify_uuid=uuid4().hex
- )
- assert f'https://edx.org/certificates/{cert.verify_uuid}?evidence_visit=1' ==\
- course_complete.evidence_url(user.id, self.course_key)
diff --git a/lms/djangoapps/badges/events/tests/test_course_meta.py b/lms/djangoapps/badges/events/tests/test_course_meta.py
deleted file mode 100644
index a4c6138529..0000000000
--- a/lms/djangoapps/badges/events/tests/test_course_meta.py
+++ /dev/null
@@ -1,184 +0,0 @@
-"""
-Tests the course meta badging events
-"""
-
-
-from unittest.mock import patch
-
-from ddt import data, ddt, unpack
-from django.conf import settings
-from django.test.utils import override_settings
-
-from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.student.tests.factories import UserFactory
-from lms.djangoapps.badges.tests.factories import CourseEventBadgesConfigurationFactory, RandomBadgeClassFactory
-from lms.djangoapps.certificates.data import CertificateStatuses
-from lms.djangoapps.certificates.models import GeneratedCertificate
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
-
-
-@ddt
-@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
-@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
-class CourseEnrollmentBadgeTest(ModuleStoreTestCase):
- """
- Tests the event which awards badges based on number of courses a user is enrolled in.
- """
-
- def setUp(self):
- super().setUp()
- self.badge_classes = [
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- ]
- nums = ['3', '5', '8']
- entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])]
- enrollment_config = '\r'.join(entries)
- self.config = CourseEventBadgesConfigurationFactory(courses_enrolled=enrollment_config)
-
- def test_no_match(self):
- """
- Make sure a badge isn't created before a user's reached any checkpoint.
- """
- user = UserFactory()
- course = CourseFactory()
- CourseEnrollment.enroll(user, course_key=course.location.course_key)
- assert not user.badgeassertion_set.all()
-
- @unpack
- @data((1, 3), (2, 5), (3, 8))
- def test_checkpoint_matches(self, checkpoint, required_badges):
- """
- Make sure the proper badges are awarded at the right checkpoints.
- """
- user = UserFactory()
- courses = [CourseFactory() for _i in range(required_badges)]
- for course in courses:
- CourseEnrollment.enroll(user, course_key=course.location.course_key)
- assertions = user.badgeassertion_set.all().order_by('id')
- assert user.badgeassertion_set.all().count() == checkpoint
- assert assertions[(checkpoint - 1)].badge_class == self.badge_classes[(checkpoint - 1)]
-
-
-@ddt
-@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
-@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
-class CourseCompletionBadgeTest(ModuleStoreTestCase):
- """
- Tests the event which awards badges based on the number of courses completed.
- """
-
- def setUp(self):
- super().setUp()
- self.badge_classes = [
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- ]
- nums = ['2', '6', '9']
- entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])]
- completed_config = '\r'.join(entries)
- self.config = CourseEventBadgesConfigurationFactory.create(courses_completed=completed_config)
- self.config.clean_fields()
-
- def test_no_match(self):
- """
- Make sure a badge isn't created before a user's reached any checkpoint.
- """
- user = UserFactory()
- course = CourseFactory()
- GeneratedCertificate(
- user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
- ).save()
- assert not user.badgeassertion_set.all()
-
- @unpack
- @data((1, 2), (2, 6), (3, 9))
- def test_checkpoint_matches(self, checkpoint, required_badges):
- """
- Make sure the proper badges are awarded at the right checkpoints.
- """
- user = UserFactory()
- courses = [CourseFactory() for _i in range(required_badges)]
- for course in courses:
- GeneratedCertificate(
- user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
- ).save()
- assertions = user.badgeassertion_set.all().order_by('id')
- assert user.badgeassertion_set.all().count() == checkpoint
- assert assertions[(checkpoint - 1)].badge_class == self.badge_classes[(checkpoint - 1)]
-
-
-@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
-@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend')
-class CourseGroupBadgeTest(ModuleStoreTestCase):
- """
- Tests the event which awards badges when a user completes a set of courses.
- """
-
- def setUp(self):
- super().setUp()
- self.badge_classes = [
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- RandomBadgeClassFactory(
- issuing_component='openedx__course'
- ),
- ]
- self.courses = []
- for _badge_class in self.badge_classes:
- self.courses.append([CourseFactory().location.course_key for _i in range(3)]) # lint-amnesty, pylint: disable=no-member
- lines = [badge_class.slug + ',' + ','.join([str(course_key) for course_key in keys])
- for badge_class, keys in zip(self.badge_classes, self.courses)]
- config = '\r'.join(lines)
- self.config = CourseEventBadgesConfigurationFactory(course_groups=config)
- self.config_map = dict(list(zip(self.badge_classes, self.courses)))
-
- def test_no_match(self):
- """
- Make sure a badge isn't created before a user's completed any course groups.
- """
- user = UserFactory()
- course = CourseFactory()
- GeneratedCertificate(
- user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable
- ).save()
- assert not user.badgeassertion_set.all()
-
- def test_group_matches(self):
- """
- Make sure the proper badges are awarded when groups are completed.
- """
- user = UserFactory()
- items = list(self.config_map.items())
- for badge_class, course_keys in items:
- for i, key in enumerate(course_keys):
- GeneratedCertificate(
- user=user, course_id=key, status=CertificateStatuses.downloadable
- ).save()
- # We don't award badges until all three are set.
- if i + 1 == len(course_keys):
- assert badge_class.get_for_user(user)
- else:
- assert not badge_class.get_for_user(user)
- classes = [badge.badge_class.id for badge in user.badgeassertion_set.all()]
- source_classes = [badge.id for badge in self.badge_classes]
- assert classes == source_classes
diff --git a/lms/djangoapps/badges/handlers.py b/lms/djangoapps/badges/handlers.py
deleted file mode 100644
index d65448128a..0000000000
--- a/lms/djangoapps/badges/handlers.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""
-Badges related signal handlers.
-"""
-
-
-from django.dispatch import receiver
-
-from common.djangoapps.student.models import EnrollStatusChange
-from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE
-from lms.djangoapps.badges.events.course_meta import award_enrollment_badge
-from lms.djangoapps.badges.utils import badges_enabled
-
-
-@receiver(ENROLL_STATUS_CHANGE)
-def award_badge_on_enrollment(sender, event=None, user=None, **kwargs): # pylint: disable=unused-argument
- """
- Awards enrollment badge to the given user on new enrollments.
- """
- if badges_enabled and event == EnrollStatusChange.enroll:
- award_enrollment_badge(user)
diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py
index 9af3901749..005cf1569a 100644
--- a/lms/djangoapps/badges/models.py
+++ b/lms/djangoapps/badges/models.py
@@ -3,24 +3,8 @@ Database models for the badges app
"""
-from importlib import import_module
-
-from config_models.models import ConfigurationModel
-from django.conf import settings
-from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.exceptions import ValidationError
-from django.db import models
from django.utils.translation import gettext_lazy as _
-from jsonfield import JSONField
-from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module
-from model_utils.models import TimeStampedModel
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.django.models import CourseKeyField
-from opaque_keys.edx.keys import CourseKey
-
-from lms.djangoapps.badges.utils import deserialize_count_specs
-from openedx.core.djangolib.markup import HTML, Text
-from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
def validate_badge_image(image):
@@ -39,298 +23,3 @@ def validate_lowercase(string):
"""
if not string.islower():
raise ValidationError(_("This value must be all lowercase."))
-
-
-class CourseBadgesDisabledError(Exception):
- """
- Exception raised when Course Badges aren't enabled, but an attempt to fetch one is made anyway.
- """
-
-
-class BadgeClass(models.Model):
- """
- Specifies a badge class to be registered with a backend.
-
- .. no_pii:
- """
- slug = models.SlugField(max_length=255, validators=[validate_lowercase])
- badgr_server_slug = models.SlugField(max_length=255, default='', blank=True)
- issuing_component = models.SlugField(max_length=50, default='', blank=True, validators=[validate_lowercase])
- display_name = models.CharField(max_length=255)
- course_id = CourseKeyField(max_length=255, blank=True, default=None)
- description = models.TextField()
- criteria = models.TextField()
- # Mode a badge was awarded for. Included for legacy/migration purposes.
- mode = models.CharField(max_length=100, default='', blank=True)
- image = models.ImageField(upload_to='badge_classes', validators=[validate_badge_image])
-
- def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
- return HTML("").format(
- slug=HTML(self.slug), issuing_component=HTML(self.issuing_component)
- )
-
- @classmethod
- def get_badge_class(
- cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None,
- mode='', course_id=None, create=True
- ):
- # TODO method should be renamed to getorcreate instead
- """
- Looks up a badge class by its slug, issuing component, and course_id and returns it should it exist.
- If it does not exist, and create is True, creates it according to the arguments. Otherwise, returns None.
-
- The expectation is that an XBlock or platform developer should not need to concern themselves with whether
- or not a badge class has already been created, but should just feed all requirements to this function
- and it will 'do the right thing'. It should be the exception, rather than the common case, that a badge class
- would need to be looked up without also being created were it missing.
- """
- slug = slug.lower()
- issuing_component = issuing_component.lower()
- if course_id and not modulestore().get_course(course_id).issue_badges:
- raise CourseBadgesDisabledError("This course does not have badges enabled.")
- if not course_id:
- course_id = CourseKeyField.Empty
- try:
- return cls.objects.get(slug=slug, issuing_component=issuing_component, course_id=course_id)
- except cls.DoesNotExist:
- if not create:
- return None
- badge_class = cls(
- slug=slug,
- issuing_component=issuing_component,
- display_name=display_name,
- course_id=course_id,
- mode=mode,
- description=description,
- criteria=criteria,
- )
- badge_class.image.save(image_file_handle.name, image_file_handle)
- badge_class.full_clean()
- badge_class.save()
- return badge_class
-
- @lazy
- def backend(self):
- """
- Loads the badging backend.
- """
- module, klass = settings.BADGING_BACKEND.rsplit('.', 1)
- module = import_module(module)
- return getattr(module, klass)()
-
- def get_for_user(self, user):
- """
- Get the assertion for this badge class for this user, if it has been awarded.
- """
- return self.badgeassertion_set.filter(user=user)
-
- def award(self, user, evidence_url=None):
- """
- Contacts the backend to have a badge assertion created for this badge class for this user.
- """
- return self.backend.award(self, user, evidence_url=evidence_url) # lint-amnesty, pylint: disable=no-member
-
- def save(self, **kwargs): # lint-amnesty, pylint: disable=arguments-differ
- """
- Slugs must always be lowercase.
- """
- self.slug = self.slug and self.slug.lower()
- self.issuing_component = self.issuing_component and self.issuing_component.lower()
- super().save(**kwargs)
-
- class Meta:
- app_label = "badges"
- unique_together = (('slug', 'issuing_component', 'course_id'),)
- verbose_name_plural = "Badge Classes"
-
-
-class BadgeAssertion(TimeStampedModel):
- """
- Tracks badges on our side of the badge baking transaction
-
- .. no_pii:
- """
- user = models.ForeignKey(User, on_delete=models.CASCADE)
- badge_class = models.ForeignKey(BadgeClass, on_delete=models.CASCADE)
- data = JSONField()
- backend = models.CharField(max_length=50)
- image_url = models.URLField()
- assertion_url = models.URLField()
-
- def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
- return HTML("<{username} Badge Assertion for {slug} for {issuing_component}").format(
- username=HTML(self.user.username),
- slug=HTML(self.badge_class.slug),
- issuing_component=HTML(self.badge_class.issuing_component),
- )
-
- @classmethod
- def assertions_for_user(cls, user, course_id=None):
- """
- Get all assertions for a user, optionally constrained to a course.
- """
- if course_id:
- return cls.objects.filter(user=user, badge_class__course_id=course_id)
- return cls.objects.filter(user=user)
-
- class Meta:
- app_label = "badges"
-
-
-# Abstract model doesn't index this, so we have to.
-BadgeAssertion._meta.get_field('created').db_index = True
-
-
-class CourseCompleteImageConfiguration(models.Model):
- """
- Contains the icon configuration for badges for a specific course mode.
-
- .. no_pii:
- """
- mode = models.CharField(
- max_length=125,
- help_text=_('The course mode for this badge image. For example, "verified" or "honor".'),
- unique=True,
- )
- icon = models.ImageField(
- # Actual max is 256KB, but need overhead for badge baking. This should be more than enough.
- help_text=_(
- "Badge images must be square PNG files. The file size should be under 250KB."
- ),
- upload_to='course_complete_badges',
- validators=[validate_badge_image]
- )
- default = models.BooleanField(
- help_text=_(
- "Set this value to True if you want this image to be the default image for any course modes "
- "that do not have a specified badge image. You can have only one default image."
- ),
- default=False,
- )
-
- def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
- return HTML("").format(
- mode=HTML(self.mode),
- default=HTML(" (default)") if self.default else HTML('')
- )
-
- def clean(self):
- """
- Make sure there's not more than one default.
- """
- if self.default and CourseCompleteImageConfiguration.objects.filter(default=True).exclude(id=self.id):
- raise ValidationError(_("There can be only one default image."))
-
- @classmethod
- def image_for_mode(cls, mode):
- """
- Get the image for a particular mode.
- """
- try:
- return cls.objects.get(mode=mode).icon
- except cls.DoesNotExist:
- # Fall back to default, if there is one.
- return cls.objects.get(default=True).icon
-
- class Meta:
- app_label = "badges"
-
-
-class CourseEventBadgesConfiguration(ConfigurationModel):
- """
- Determines the settings for meta course awards-- such as completing a certain
- number of courses or enrolling in a certain number of them.
-
- .. no_pii:
- """
- courses_completed = models.TextField(
- blank=True, default='',
- help_text=_(
- "On each line, put the number of completed courses to award a badge for, a comma, and the slug of a "
- "badge class you have created that has the issuing component 'openedx__course'. "
- "For example: 3,enrolled_3_courses"
- )
- )
- courses_enrolled = models.TextField(
- blank=True, default='',
- help_text=_(
- "On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a "
- "badge class you have created that has the issuing component 'openedx__course'. "
- "For example: 3,enrolled_3_courses"
- )
- )
- course_groups = models.TextField(
- blank=True, default='',
- help_text=_(
- "Each line is a comma-separated list. The first item in each line is the slug of a badge class you "
- "have created that has an issuing component of 'openedx__course'. The remaining items in each line are "
- "the course keys the learner needs to complete to be awarded the badge. For example: "
- "slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second"
- )
- )
-
- def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned
- return HTML("").format(
- Text("Enabled") if self.enabled else Text("Disabled")
- )
-
- @property
- def completed_settings(self):
- """
- Parses the settings from the courses_completed field.
- """
- return deserialize_count_specs(self.courses_completed)
-
- @property
- def enrolled_settings(self):
- """
- Parses the settings from the courses_completed field.
- """
- return deserialize_count_specs(self.courses_enrolled)
-
- @property
- def course_group_settings(self):
- """
- Parses the course group settings. In example, the format is:
-
- slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second
- """
- specs = self.course_groups.strip()
- if not specs:
- return {}
- specs = [line.split(',', 1) for line in specs.splitlines()]
- return {
- slug.strip().lower(): [CourseKey.from_string(key.strip()) for key in keys.strip().split(',')]
- for slug, keys in specs
- }
-
- def clean_fields(self, exclude=tuple()):
- """
- Verify the settings are parseable.
- """
- errors = {}
- error_message = _("Please check the syntax of your entry.")
- if 'courses_completed' not in exclude:
- try:
- self.completed_settings
- except (ValueError, InvalidKeyError):
- errors['courses_completed'] = [str(error_message)]
- if 'courses_enrolled' not in exclude:
- try:
- self.enrolled_settings
- except (ValueError, InvalidKeyError):
- errors['courses_enrolled'] = [str(error_message)]
- if 'course_groups' not in exclude:
- store = modulestore()
- try:
- for key_list in self.course_group_settings.values():
- for course_key in key_list:
- if not store.get_course(course_key):
- ValueError(f"The course {course_key} does not exist.")
- except (ValueError, InvalidKeyError):
- errors['course_groups'] = [str(error_message)]
- if errors:
- raise ValidationError(errors)
-
- class Meta:
- app_label = "badges"
diff --git a/lms/djangoapps/badges/service.py b/lms/djangoapps/badges/service.py
deleted file mode 100644
index 2870ac7df4..0000000000
--- a/lms/djangoapps/badges/service.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""
-Badging service for XBlocks
-"""
-
-
-from lms.djangoapps.badges.models import BadgeClass
-
-
-class BadgingService:
- """
- A class that provides functions for managing badges which XBlocks can use.
-
- If course_enabled is True, course-level badges are permitted for this course.
-
- If it is False, any badges that are awarded should be non-course specific.
- """
- course_badges_enabled = False
-
- def __init__(self, course_id=None, modulestore=None):
- """
- Sets the 'course_badges_enabled' parameter.
- """
- if not (course_id and modulestore):
- return
-
- course = modulestore.get_course(course_id)
- if course:
- self.course_badges_enabled = course.issue_badges
-
- get_badge_class = BadgeClass.get_badge_class
diff --git a/lms/djangoapps/badges/tests/__init__.py b/lms/djangoapps/badges/tests/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/lms/djangoapps/badges/tests/factories.py b/lms/djangoapps/badges/tests/factories.py
deleted file mode 100644
index f84053e329..0000000000
--- a/lms/djangoapps/badges/tests/factories.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""
-Factories for Badge tests
-"""
-
-
-from random import random
-
-import factory
-from django.core.files.base import ContentFile
-from factory.django import ImageField
-
-from common.djangoapps.student.tests.factories import UserFactory
-from lms.djangoapps.badges.models import ( # lint-amnesty, pylint: disable=line-too-long
- BadgeAssertion,
- BadgeClass,
- CourseCompleteImageConfiguration,
- CourseEventBadgesConfiguration
-)
-
-
-def generate_dummy_image(_unused):
- """
- Used for image fields to create a sane default.
- """
- return ContentFile(
- ImageField()._make_data( # pylint: disable=protected-access
- {'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'}
- ), 'test.png'
- )
-
-
-class CourseCompleteImageConfigurationFactory(factory.django.DjangoModelFactory):
- """
- Factory for BadgeImageConfigurations
- """
- class Meta:
- model = CourseCompleteImageConfiguration
-
- mode = 'honor'
- icon = factory.LazyAttribute(generate_dummy_image)
-
-
-class BadgeClassFactory(factory.django.DjangoModelFactory):
- """
- Factory for BadgeClass
- """
- class Meta:
- model = BadgeClass
-
- slug = 'test_slug'
- badgr_server_slug = 'test_badgr_server_slug'
- issuing_component = 'test_component'
- display_name = 'Test Badge'
- description = "Yay! It's a test badge."
- criteria = 'https://example.com/syllabus'
- mode = 'honor'
- image = factory.LazyAttribute(generate_dummy_image)
-
-
-class RandomBadgeClassFactory(BadgeClassFactory):
- """
- Same as BadgeClassFactory, but randomize the slug.
- """
- slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random()).replace('.', '_'))
-
-
-class BadgeAssertionFactory(factory.django.DjangoModelFactory):
- """
- Factory for BadgeAssertions
- """
- class Meta:
- model = BadgeAssertion
-
- user = factory.SubFactory(UserFactory)
- badge_class = factory.SubFactory(RandomBadgeClassFactory)
- data = {}
- assertion_url = 'http://example.com/example.json'
- image_url = 'http://example.com/image.png'
-
-
-class CourseEventBadgesConfigurationFactory(factory.django.DjangoModelFactory):
- """
- Factory for CourseEventsBadgesConfiguration
- """
- class Meta:
- model = CourseEventBadgesConfiguration
-
- enabled = True
diff --git a/lms/djangoapps/badges/tests/test_models.py b/lms/djangoapps/badges/tests/test_models.py
deleted file mode 100644
index e57c2e1b23..0000000000
--- a/lms/djangoapps/badges/tests/test_models.py
+++ /dev/null
@@ -1,311 +0,0 @@
-"""
-Tests for the Badges app models.
-"""
-
-
-import contextlib
-from unittest.mock import Mock, patch
-
-import pytest
-from django.core.exceptions import ValidationError
-from django.core.files.images import ImageFile
-from django.core.files.storage import default_storage
-from django.db.utils import IntegrityError
-from django.test import TestCase
-from django.test.utils import override_settings
-from path import Path
-
-from common.djangoapps.student.tests.factories import UserFactory
-from lms.djangoapps.badges.models import (
- BadgeAssertion,
- BadgeClass,
- CourseBadgesDisabledError,
- CourseCompleteImageConfiguration,
- validate_badge_image
-)
-from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory
-from lms.djangoapps.certificates.tests.test_models import TEST_DATA_ROOT, TEST_DATA_DIR
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
-
-
-@contextlib.contextmanager
-def get_image(name):
- """
- Get one of the test images from the test data directory.
- """
- with open(f'{TEST_DATA_DIR}/badges/{name}.png', mode='rb') as fimage:
- yield ImageFile(fimage)
-
-
-@override_settings(MEDIA_ROOT=TEST_DATA_ROOT)
-class BadgeImageConfigurationTest(TestCase):
- """
- Test the validation features of BadgeImageConfiguration.
- """
-
- def tearDown(self): # lint-amnesty, pylint: disable=super-method-not-called
- tmp_path = Path(TEST_DATA_ROOT / 'course_complete_badges')
- Path.rmtree_p(tmp_path)
-
- def test_no_double_default(self):
- """
- Verify that creating two configurations as default is not permitted.
- """
- with get_image('good') as image_handle:
- CourseCompleteImageConfiguration(mode='test', icon=ImageFile(image_handle), default=True).save()
- with get_image('good') as image_handle:
- pytest.raises(ValidationError, CourseCompleteImageConfiguration(mode='test2', icon=ImageFile(image_handle),
- default=True).full_clean)
-
- def test_runs_validator(self):
- """
- Verify that the image validator is triggered when cleaning the model.
- """
- with get_image('unbalanced') as image_handle:
- pytest.raises(
- ValidationError,
- CourseCompleteImageConfiguration(mode='test2', icon=ImageFile(image_handle)).full_clean
- )
-
-
-class DummyBackend:
- """
- Dummy badge backend, used for testing.
- """
- award = Mock()
-
-
-@override_settings(MEDIA_ROOT=TEST_DATA_ROOT)
-class BadgeClassTest(ModuleStoreTestCase):
- """
- Test BadgeClass functionality
- """
-
- def setUp(self):
- super().setUp()
- self.addCleanup(self.cleanup_uploads)
-
- def cleanup_uploads(self):
- """
- Remove all files uploaded as badges.
- """
- upload_to = BadgeClass._meta.get_field('image').upload_to
- if default_storage.exists(upload_to):
- (_, files) = default_storage.listdir(upload_to)
- for uploaded_file in files:
- default_storage.delete(upload_to + '/' + uploaded_file)
-
- # Need full path to make sure class names line up.
- @override_settings(BADGING_BACKEND='lms.djangoapps.badges.tests.test_models.DummyBackend')
- def test_backend(self):
- """
- Verify the BadgeClass fetches the backend properly.
- """
- assert isinstance(BadgeClass().backend, DummyBackend)
-
- def test_get_badge_class_preexisting(self):
- """
- Verify fetching a badge first grabs existing badges.
- """
- premade_badge_class = BadgeClassFactory.create()
- # Ignore additional parameters. This class already exists.
- with get_image('good') as image_handle:
- badge_class = BadgeClass.get_badge_class(
- slug='test_slug', issuing_component='test_component', description='Attempted override',
- criteria='test', display_name='Testola', image_file_handle=image_handle
- )
- # These defaults are set on the factory.
- assert badge_class.criteria == 'https://example.com/syllabus'
- assert badge_class.display_name == 'Test Badge'
- assert badge_class.description == "Yay! It's a test badge."
- # File name won't always be the same.
- assert badge_class.image.path == premade_badge_class.image.path
-
- def test_unique_for_course(self):
- """
- Verify that the course_id is used in fetching existing badges or creating new ones.
- """
- course_key = CourseFactory.create().location.course_key
- premade_badge_class = BadgeClassFactory.create(course_id=course_key)
- with get_image('good') as image_handle:
- badge_class = BadgeClass.get_badge_class(
- slug='test_slug', issuing_component='test_component', description='Attempted override',
- criteria='test', display_name='Testola', image_file_handle=image_handle
- )
- with get_image('good') as image_handle:
- course_badge_class = BadgeClass.get_badge_class(
- slug='test_slug', issuing_component='test_component', description='Attempted override',
- criteria='test', display_name='Testola', image_file_handle=image_handle,
- course_id=course_key,
- )
- assert badge_class.id != course_badge_class.id
- assert course_badge_class.id == premade_badge_class.id
-
- def test_get_badge_class_course_disabled(self):
- """
- Verify attempting to fetch a badge class for a course which does not issue badges raises an
- exception.
- """
- course_key = CourseFactory.create(metadata={'issue_badges': False}).location.course_key
- with pytest.raises(CourseBadgesDisabledError):
- with get_image('good') as image_handle:
- BadgeClass.get_badge_class(
- slug='test_slug', issuing_component='test_component', description='Attempted override',
- criteria='test', display_name='Testola', image_file_handle=image_handle,
- course_id=course_key,
- )
-
- def test_get_badge_class_create(self):
- """
- Verify fetching a badge creates it if it doesn't yet exist.
- """
- with get_image('good') as image_handle:
- badge_class = BadgeClass.get_badge_class(
- slug='new_slug', issuing_component='new_component', description='This is a test',
- criteria='https://example.com/test_criteria', display_name='Super Badge',
- image_file_handle=image_handle
- )
- # This should have been saved before being passed back.
- assert badge_class.id
- assert badge_class.slug == 'new_slug'
- assert badge_class.issuing_component == 'new_component'
- assert badge_class.description == 'This is a test'
- assert badge_class.criteria == 'https://example.com/test_criteria'
- assert badge_class.display_name == 'Super Badge'
- assert 'good' in badge_class.image.name.rsplit('/', 1)[(- 1)]
-
- def test_get_badge_class_nocreate(self):
- """
- Test returns None if the badge class does not exist.
- """
- badge_class = BadgeClass.get_badge_class(
- slug='new_slug', issuing_component='new_component', create=False
- )
- assert badge_class is None
- # Run this twice to verify there wasn't a background creation of the badge.
- badge_class = BadgeClass.get_badge_class(
- slug='new_slug', issuing_component='new_component', description=None,
- criteria=None, display_name=None,
- image_file_handle=None, create=False
- )
- assert badge_class is None
-
- def test_get_badge_class_image_validate(self):
- """
- Verify handing a broken image to get_badge_class raises a validation error upon creation.
- """
- # TODO Test should be updated, this doc doesn't makes sense, the object eventually gets created
- with get_image('unbalanced') as image_handle:
- self.assertRaises(
- ValidationError,
- BadgeClass.get_badge_class,
- slug='new_slug', issuing_component='new_component', description='This is a test',
- criteria='https://example.com/test_criteria', display_name='Super Badge',
- image_file_handle=image_handle
- )
-
- def test_get_badge_class_data_validate(self):
- """
- Verify handing incomplete data for required fields when making a badge class raises an Integrity error.
- """
- with pytest.raises(IntegrityError), self.allow_transaction_exception():
- with get_image('good') as image_handle:
- BadgeClass.get_badge_class(
- slug='new_slug', issuing_component='new_component', image_file_handle=image_handle
- )
-
- def test_get_for_user(self):
- """
- Make sure we can get an assertion for a user if there is one.
- """
- user = UserFactory.create()
- badge_class = BadgeClassFactory.create()
- assert not badge_class.get_for_user(user)
- assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=user)
- assert list(badge_class.get_for_user(user)) == [assertion]
-
- @override_settings(
- BADGING_BACKEND='lms.djangoapps.badges.backends.badgr.BadgrBackend',
- BADGR_USERNAME='example@example.com',
- BADGR_PASSWORD='password',
- BADGR_TOKENS_CACHE_KEY='badgr-test-cache-key')
- @patch('lms.djangoapps.badges.backends.badgr.BadgrBackend.award')
- def test_award(self, mock_award):
- """
- Verify that the award command calls the award function on the backend with the right parameters.
- """
- user = UserFactory.create()
- badge_class = BadgeClassFactory.create()
- badge_class.award(user, evidence_url='http://example.com/evidence')
- assert mock_award.called
- mock_award.assert_called_with(badge_class, user, evidence_url='http://example.com/evidence')
-
- def test_runs_validator(self):
- """
- Verify that the image validator is triggered when cleaning the model.
- """
- with get_image('unbalanced') as image_handle:
- pytest.raises(
- ValidationError,
- BadgeClass(
- slug='test', issuing_component='test2', criteria='test3',
- description='test4', image=ImageFile(image_handle)).full_clean
- )
-
-
-class BadgeAssertionTest(ModuleStoreTestCase):
- """
- Tests for the BadgeAssertion model
- """
- def test_assertions_for_user(self):
- """
- Verify that grabbing all assertions for a user behaves as expected.
-
- This function uses object IDs because for some reason Jenkins trips up
- on its assertCountEqual check here despite the items being equal.
- """
- user = UserFactory()
- assertions = [BadgeAssertionFactory.create(user=user).id for _i in range(3)]
- course = CourseFactory.create()
- course_key = course.location.course_key
- course_badges = [RandomBadgeClassFactory(course_id=course_key) for _i in range(3)]
- course_assertions = [
- BadgeAssertionFactory.create(user=user, badge_class=badge_class).id for badge_class in course_badges
- ]
- assertions.extend(course_assertions)
- assertions.sort()
- assertions_for_user = [badge.id for badge in BadgeAssertion.assertions_for_user(user)]
- assertions_for_user.sort()
- assert assertions_for_user == assertions
- course_scoped_assertions = [
- badge.id for badge in BadgeAssertion.assertions_for_user(user, course_id=course_key)
- ]
- course_scoped_assertions.sort()
- assert course_scoped_assertions == course_assertions
-
-
-class ValidBadgeImageTest(TestCase):
- """
- Tests the badge image field validator.
- """
- def test_good_image(self):
- """
- Verify that saving a valid badge image is no problem.
- """
- with get_image('good') as image_handle:
- validate_badge_image(ImageFile(image_handle))
-
- def test_unbalanced_image(self):
- """
- Verify that setting an image with an uneven width and height raises an error.
- """
- with get_image('unbalanced') as image_handle:
- self.assertRaises(ValidationError, validate_badge_image, ImageFile(image_handle))
-
- def test_large_image(self):
- """
- Verify that setting an image that is too big raises an error.
- """
- with get_image('large') as image_handle:
- self.assertRaises(ValidationError, validate_badge_image, ImageFile(image_handle))
diff --git a/lms/djangoapps/badges/utils.py b/lms/djangoapps/badges/utils.py
deleted file mode 100644
index 150834e04c..0000000000
--- a/lms/djangoapps/badges/utils.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Utility functions used by the badging app.
-"""
-
-
-from django.conf import settings
-
-
-def site_prefix():
- """
- Get the prefix for the site URL-- protocol and server name.
- """
- scheme = "https" if settings.HTTPS == "on" else "http"
- return f'{scheme}://{settings.SITE_NAME}'
-
-
-def requires_badges_enabled(function):
- """
- Decorator that bails a function out early if badges aren't enabled.
- """
- def wrapped(*args, **kwargs):
- """
- Wrapped function which bails out early if bagdes aren't enabled.
- """
- if not badges_enabled():
- return
- return function(*args, **kwargs)
- return wrapped
-
-
-def badges_enabled():
- """
- returns a boolean indicating whether or not openbadges are enabled.
- """
- return settings.FEATURES.get('ENABLE_OPENBADGES', False)
-
-
-def deserialize_count_specs(text):
- """
- Takes a string in the format of:
- int,course_key
- int,course_key
-
- And returns a dictionary with the keys as the numbers and the values as the course keys.
- """
- specs = text.splitlines()
- specs = [line.split(',') for line in specs if line.strip()]
- return {int(num): slug.strip().lower() for num, slug in specs}
diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py
index a618545bcd..fe0f70fbdc 100644
--- a/lms/djangoapps/bulk_email/tests/test_course_optout.py
+++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py
@@ -40,7 +40,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
# load initial content (since we don't run migrations as part of tests):
call_command("loaddata", "course_email_template.json")
- self.client.login(username=self.student.username, password="test")
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
self.send_mail_url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
self.success_content = {
@@ -70,7 +70,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
self.client.logout()
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.navigate_to_email_view()
test_email = {
@@ -92,7 +92,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
"""
self.client.logout()
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
unsubscribe_link = get_unsubscribed_link(self.student.username, str(self.course.id))
response = self.client.post(unsubscribe_link, {'unsubscribe': True})
@@ -122,7 +122,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase):
assert CourseEnrollment.is_enrolled(self.student, self.course.id)
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.navigate_to_email_view()
test_email = {
@@ -155,7 +155,7 @@ class TestACEOptoutCourseEmails(ModuleStoreTestCase):
self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
- self.client.login(username=self.student.username, password="test")
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
self._set_email_optout(False)
self.policy = CourseEmailOptout()
diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py
index b67864381b..ba95bd26dd 100644
--- a/lms/djangoapps/bulk_email/tests/test_email.py
+++ b/lms/djangoapps/bulk_email/tests/test_email.py
@@ -87,7 +87,7 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase):
"""
Log in self.client as user.
"""
- self.client.login(username=user.username, password="test")
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
def goto_instructor_dash_email_view(self):
"""
diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py
index 3f3ffe9b69..06e9a1dce4 100644
--- a/lms/djangoapps/bulk_email/tests/test_err_handling.py
+++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py
@@ -53,7 +53,7 @@ class TestEmailErrors(ModuleStoreTestCase):
course_title = "ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ"
self.course = CourseFactory.create(display_name=course_title)
self.instructor = AdminFactory.create()
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
# load initial content (since we don't run migrations as part of tests):
call_command("loaddata", "course_email_template.json")
diff --git a/lms/djangoapps/bulk_email/tests/test_signals.py b/lms/djangoapps/bulk_email/tests/test_signals.py
index ea369fa805..1a3715284b 100644
--- a/lms/djangoapps/bulk_email/tests/test_signals.py
+++ b/lms/djangoapps/bulk_email/tests/test_signals.py
@@ -33,7 +33,7 @@ class TestOptoutCourseEmailsBySignal(ModuleStoreTestCase):
# load initial content (since we don't run migrations as part of tests):
call_command("loaddata", "course_email_template.json")
- self.client.login(username=self.student.username, password="test")
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
self.send_mail_url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
self.success_content = {
@@ -78,7 +78,7 @@ class TestOptoutCourseEmailsBySignal(ModuleStoreTestCase):
force_optout_all(sender=self.__class__, user=self.student)
# Try to send a bulk course email
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.send_test_email()
# Assert that self.student.email not in mail.to, outbox should only contain "myself" target
diff --git a/lms/djangoapps/ccx/api/v0/tests/test_views.py b/lms/djangoapps/ccx/api/v0/tests/test_views.py
index d3daf72244..7279b94263 100644
--- a/lms/djangoapps/ccx/api/v0/tests/test_views.py
+++ b/lms/djangoapps/ccx/api/v0/tests/test_views.py
@@ -33,7 +33,7 @@ from lms.djangoapps.instructor.access import allow_access, list_with_level
from lms.djangoapps.instructor.enrollment import enroll_email, get_email_params
from openedx.core.lib.courses import get_course_by_id
-USER_PASSWORD = 'test'
+USER_PASSWORD = 'Password1234'
class CcxRestApiTest(CcxTestCase, APITestCase):
@@ -760,7 +760,7 @@ class CcxDetailTest(CcxRestApiTest):
"""
# create a staff user
staff_user = UserFactory.create(
- username='test_staff_user', email='test_staff_user@openedx.org', password='test',
+ username='test_staff_user', email='test_staff_user@openedx.org', password=USER_PASSWORD,
)
# add staff role to the staff user
CourseStaffRole(self.master_course_key).add_users(staff_user)
@@ -779,7 +779,7 @@ class CcxDetailTest(CcxRestApiTest):
"""
# create an instructor user
instructor_user = UserFactory.create(
- username='test_instructor_user', email='test_instructor_user@openedx.org', password='test',
+ username='test_instructor_user', email='test_instructor_user@openedx.org', password=USER_PASSWORD,
)
# add instructor role to the instructor user
CourseInstructorRole(self.master_course_key).add_users(instructor_user)
@@ -798,7 +798,7 @@ class CcxDetailTest(CcxRestApiTest):
"""
# create an coach user
coach_user = UserFactory.create(
- username='test_coach_user', email='test_coach_user@openedx.org', password='test',
+ username='test_coach_user', email='test_coach_user@openedx.org', password=USER_PASSWORD,
)
# add coach role to the coach user
CourseCcxCoachRole(self.master_course_key).add_users(coach_user)
diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py
index b18ed5c2aa..e993d5dc0b 100644
--- a/lms/djangoapps/ccx/tests/test_views.py
+++ b/lms/djangoapps/ccx/tests/test_views.py
@@ -129,13 +129,14 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
ccx = self.make_ccx()
ccx_key = CCXLocator.from_course_locator(self.course.id, ccx.id)
self.url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_key})
+ self.TEST_PASSWORD = 'Password1234'
def test_staff_access_coach_dashboard(self):
"""
User is staff, should access coach dashboard.
"""
staff = self.make_staff()
- self.client.login(username=staff.username, password="test")
+ self.client.login(username=staff.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
assert response.status_code == 200
@@ -145,7 +146,7 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
User is instructor, should access coach dashboard.
"""
instructor = self.make_instructor()
- self.client.login(username=instructor.username, password="test")
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
# Now access URL
response = self.client.get(self.url)
@@ -155,8 +156,8 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
"""
Assert user with no access must not see dashboard.
"""
- user = UserFactory.create(password="test")
- self.client.login(username=user.username, password="test")
+ user = UserFactory.create(password=self.TEST_PASSWORD)
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
assert response.status_code == 403
@@ -211,12 +212,12 @@ class TestCCXProgressChanges(CcxTestCase, LoginEnrollmentTestCase):
"""
assert signal and schedule update.
"""
- student = UserFactory.create(is_staff=False, password="test")
+ student = UserFactory.create(is_staff=False, password=self.TEST_PASSWORD)
CourseEnrollment.enroll(student, ccx_course_key)
assert CourseEnrollment.objects.filter(course_id=ccx_course_key, user=student).exists()
# login as student
- self.client.login(username=student.username, password="test")
+ self.client.login(username=student.username, password=self.TEST_PASSWORD)
progress_page_response = self.client.get(
reverse('progress', kwargs={'course_id': ccx_course_key})
)
@@ -236,7 +237,7 @@ class TestCCXProgressChanges(CcxTestCase, LoginEnrollmentTestCase):
self.make_coach()
ccx = self.make_ccx()
ccx_course_key = CCXLocator.from_course_locator(self.course.id, str(ccx.id))
- self.client.login(username=self.coach.username, password="test")
+ self.client.login(username=self.coach.username, password=self.TEST_PASSWORD)
url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_course_key})
response = self.client.get(url)
@@ -295,7 +296,7 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
"""
super().setUp()
# Login with the instructor account
- self.client.login(username=self.coach.username, password="test")
+ self.client.login(username=self.coach.username, password=self.TEST_PASSWORD)
# adding staff to master course.
staff = UserFactory()
@@ -315,8 +316,8 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
ccx = self.make_ccx()
# create session of non-coach user
- user = UserFactory.create(password="test")
- self.client.login(username=user.username, password="test")
+ user = UserFactory.create(password=self.TEST_PASSWORD)
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
url = reverse(
'ccx_coach_dashboard',
kwargs={'course_id': CCXLocator.from_course_locator(self.course.id, ccx.id)})
@@ -841,7 +842,7 @@ class TestCoachDashboardSchedule(CcxTestCase, LoginEnrollmentTestCase, ModuleSto
self.mstore = modulestore()
# Login with the instructor account
- self.client.login(username=self.coach.username, password="test")
+ self.client.login(username=self.coach.username, password=self.TEST_PASSWORD)
# adding staff to master course.
staff = UserFactory()
@@ -981,7 +982,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro
# Create instructor account
self.coach = coach = AdminFactory.create()
- self.client.login(username=coach.username, password="test")
+ self.client.login(username=coach.username, password=self.TEST_PASSWORD)
# Create CCX
role = CourseCcxCoachRole(self._course.id)
@@ -1009,7 +1010,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro
self.course = get_course_by_id(self.ccx_key, depth=None)
CourseOverview.load_from_module_store(self.course.id)
setup_students_and_grades(self)
- self.client.login(username=coach.username, password="test")
+ self.client.login(username=coach.username, password=self.TEST_PASSWORD)
self.addCleanup(RequestCache.clear_all_namespaces)
from xmodule.modulestore.django import SignalHandler
@@ -1068,7 +1069,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro
get_course.return_value = self.course
self.addCleanup(patch_context.stop)
- self.client.login(username=self.student.username, password="test") # lint-amnesty, pylint: disable=no-member
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD) # lint-amnesty, pylint: disable=no-member
url = reverse(
'progress',
kwargs={'course_id': self.ccx_key}
@@ -1194,7 +1195,7 @@ class TestStudentViewsWithCCX(ModuleStoreTestCase):
# Create a Split Mongo course and enroll a student user in it.
self.student_password = "foobar"
- self.student = UserFactory.create(username="test", password=self.student_password, is_staff=False)
+ self.student = UserFactory.create(username=self.TEST_PASSWORD, password=self.student_password, is_staff=False)
self.split_course = SampleCourseFactory.create(default_store=ModuleStoreEnum.Type.split)
CourseEnrollment.enroll(self.student, self.split_course.id)
diff --git a/lms/djangoapps/ccx/tests/utils.py b/lms/djangoapps/ccx/tests/utils.py
index 4d36353b9a..4f8c34e14c 100644
--- a/lms/djangoapps/ccx/tests/utils.py
+++ b/lms/djangoapps/ccx/tests/utils.py
@@ -68,7 +68,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase):
"""
super().setUp()
# Create instructor account
- self.coach = UserFactory.create(password="test")
+ self.coach = UserFactory.create()
# create an instance of modulestore
self.mstore = modulestore()
@@ -76,7 +76,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase):
"""
create staff user.
"""
- staff = UserFactory.create(password="test")
+ staff = UserFactory.create()
role = CourseStaffRole(self.course.id)
role.add_users(staff)
@@ -86,7 +86,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase):
"""
create instructor user.
"""
- instructor = UserFactory.create(password="test")
+ instructor = UserFactory.create()
role = CourseInstructorRole(self.course.id)
role.add_users(instructor)
diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py
index c76e34a95b..c93bc85a42 100644
--- a/lms/djangoapps/certificates/api.py
+++ b/lms/djangoapps/certificates/api.py
@@ -49,6 +49,8 @@ from lms.djangoapps.certificates.utils import (
certificate_status_for_student as _certificate_status_for_student,
)
from lms.djangoapps.instructor import access
+from lms.djangoapps.utils import _get_key
+from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
@@ -920,3 +922,34 @@ def _has_passed_or_is_allowlisted(course, student, course_grade):
has_passed = course_grade and course_grade.passed
return has_passed or is_allowlisted
+
+
+def invalidate_certificate(user_id, course_key_or_id, source):
+ """
+ Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this
+ course run.
+
+ This function is called in services.py and handlers.py within the certificates folder. As of now,
+ The call in services.py occurs when an exam attempt is rejected in the legacy exams backend, edx-proctoring.
+ The call in handlers.py is occurs when an exam attempt is rejected in the newer exams backend, edx-exams.
+ """
+ course_key = _get_key(course_key_or_id, CourseKey)
+ if _is_on_certificate_allowlist(user_id, course_key):
+ log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.')
+ return False
+
+ try:
+ generated_certificate = GeneratedCertificate.objects.get(
+ user=user_id,
+ course_id=course_key
+ )
+ generated_certificate.invalidate(source=source)
+ except ObjectDoesNotExist:
+ log.warning(
+ 'Invalidation failed because a certificate for user %d in course %s does not exist.',
+ user_id,
+ course_key
+ )
+ return False
+
+ return True
diff --git a/lms/djangoapps/certificates/config.py b/lms/djangoapps/certificates/config.py
index 6df62ae563..92b20b17bb 100644
--- a/lms/djangoapps/certificates/config.py
+++ b/lms/djangoapps/certificates/config.py
@@ -23,6 +23,7 @@ AUTO_CERTIFICATE_GENERATION = WaffleSwitch(f"{WAFFLE_NAMESPACE}.auto_certificate
# .. toggle_description: When True, the system will publish `CERTIFICATE_CREATED` signals to the event bus. The
# `CERTIFICATE_CREATED` signal is emit when a certificate has been awarded to a learner and the creation process has
# completed.
+# .. toggle_warning: Will be deprecated in favor of SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2023-04-11
# .. toggle_target_removal_date: 2023-07-31
@@ -36,6 +37,7 @@ SEND_CERTIFICATE_CREATED_SIGNAL = SettingToggle('SEND_CERTIFICATE_CREATED_SIGNAL
# .. toggle_description: When True, the system will publish `CERTIFICATE_REVOKED` signals to the event bus. The
# `CERTIFICATE_REVOKED` signal is emit when a certificate has been revoked from a learner and the revocation process
# has completed.
+# .. toggle_warning: Will be deprecated in favor of SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2023-09-15
# .. toggle_target_removal_date: 2024-01-01
diff --git a/lms/djangoapps/certificates/handlers.py b/lms/djangoapps/certificates/handlers.py
new file mode 100644
index 0000000000..8d45468497
--- /dev/null
+++ b/lms/djangoapps/certificates/handlers.py
@@ -0,0 +1,28 @@
+"""
+Handlers for credits
+"""
+import logging
+
+from django.contrib.auth import get_user_model
+from django.dispatch import receiver
+from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED
+
+from lms.djangoapps.certificates.api import invalidate_certificate
+
+User = get_user_model()
+
+log = logging.getLogger(__name__)
+
+
+@receiver(EXAM_ATTEMPT_REJECTED)
+def handle_exam_attempt_rejected_event(sender, signal, **kwargs):
+ """
+ Consume `EXAM_ATTEMPT_REJECTED` events from the event bus.
+ Pass the received data to invalidate_certificate in the services.py file in this folder.
+ """
+ event_data = kwargs.get('exam_attempt')
+ user_data = event_data.student_user
+ course_key = event_data.course_key
+
+ # Note that the course_key is the same as the course_key_or_id, and is being passed in as the course_key param
+ invalidate_certificate(user_data.id, course_key, source='exam_event')
diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py
index 7b5677a690..fa16112f3a 100644
--- a/lms/djangoapps/certificates/models.py
+++ b/lms/djangoapps/certificates/models.py
@@ -26,8 +26,6 @@ from simple_history.models import HistoricalRecords
from common.djangoapps.student import models_api as student_api
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
-from lms.djangoapps.badges.events.course_complete import course_badge_check
-from lms.djangoapps.badges.events.course_meta import completion_check, course_group_check
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.instructor_task.models import InstructorTask
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED
@@ -1245,31 +1243,6 @@ class CertificateTemplateAsset(TimeStampedModel):
app_label = "certificates"
-@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
-# pylint: disable=unused-argument
-def create_course_badge(sender, user, course_key, status, **kwargs):
- """
- Standard signal hook to create course badges when a certificate has been generated.
- """
- course_badge_check(user, course_key)
-
-
-@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
-def create_completion_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument
- """
- Standard signal hook to create 'x courses completed' badges when a certificate has been generated.
- """
- completion_check(user)
-
-
-@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate)
-def create_course_group_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument
- """
- Standard signal hook to create badges when a user has completed a prespecified set of courses.
- """
- course_group_check(user, course_key)
-
-
class CertificateGenerationCommandConfiguration(ConfigurationModel):
"""
Manages configuration for a run of the cert_generation management command.
diff --git a/lms/djangoapps/certificates/services.py b/lms/djangoapps/certificates/services.py
index 29ee5a05d3..508bb3ad6d 100644
--- a/lms/djangoapps/certificates/services.py
+++ b/lms/djangoapps/certificates/services.py
@@ -2,17 +2,7 @@
Certificate service
"""
-
-import logging
-
-from django.core.exceptions import ObjectDoesNotExist
-from opaque_keys.edx.keys import CourseKey
-
-from lms.djangoapps.certificates.generation_handler import is_on_certificate_allowlist
-from lms.djangoapps.certificates.models import GeneratedCertificate
-from lms.djangoapps.utils import _get_key
-
-log = logging.getLogger(__name__)
+from lms.djangoapps.certificates.api import invalidate_certificate
class CertificateService:
@@ -21,27 +11,6 @@ class CertificateService:
"""
def invalidate_certificate(self, user_id, course_key_or_id):
- """
- Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this
- course run.
- """
- course_key = _get_key(course_key_or_id, CourseKey)
- if is_on_certificate_allowlist(user_id, course_key):
- log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.')
- return False
-
- try:
- generated_certificate = GeneratedCertificate.objects.get(
- user=user_id,
- course_id=course_key
- )
- generated_certificate.invalidate(source='certificate_service')
- except ObjectDoesNotExist:
- log.warning(
- 'Invalidation failed because a certificate for user %d in course %s does not exist.',
- user_id,
- course_key
- )
- return False
-
- return True
+ # The original code for this function was moved to this helper function to be call-able
+ # By both the legacy and current exams backends (edx-proctoring and edx-exams).
+ return invalidate_certificate(user_id, course_key_or_id, source='certificate_service')
diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py
index 4d43f0ed49..b6858c1445 100644
--- a/lms/djangoapps/certificates/signals.py
+++ b/lms/djangoapps/certificates/signals.py
@@ -3,10 +3,12 @@ Signal handler for enabling/disabling self-generated certificates based on the c
"""
import logging
+from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
from openedx_events.event_bus import get_producer
+from edx_django_utils.monitoring import set_custom_attribute
from common.djangoapps.course_modes import api as modes_api
from common.djangoapps.student.models import CourseEnrollment
@@ -27,6 +29,7 @@ from lms.djangoapps.certificates.models import (
from lms.djangoapps.certificates.api import auto_certificate_generation_enabled
from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.djangoapps.content.course_overviews.signals import COURSE_PACING_CHANGED
+from openedx.core.lib.events import determine_producer_config_for_signal_and_topic
from openedx.core.djangoapps.signals.signals import (
COURSE_GRADE_NOW_FAILED,
COURSE_GRADE_NOW_PASSED,
@@ -161,12 +164,43 @@ def _listen_for_enrollment_mode_change(sender, user, course_key, mode, **kwargs)
return False
+def _determine_producer_config_for_signal_and_topic(signal, topic):
+ """
+ Utility method to determine the setting for the given signal and topic in EVENT_BUS_PRODUCER_CONFIG
+
+ Records to New Relic for later analysis.
+
+ Parameters
+ signal (OpenEdxPublicSignal): The signal being sent to the event bus
+ topic (string): The topic to which the signal is being sent (without environment prefix)
+
+ Returns
+ True if the signal is enabled for that topic in EVENT_BUS_PRODUCER_CONFIG
+ False if the signal is explicitly disabled for that topic in EVENT_BUS_PRODUCER_CONFIG
+ None if the signal/topic pair is not present in EVENT_BUS_PRODUCER_CONFIG
+ """
+ event_type_producer_configs = getattr(settings, "EVENT_BUS_PRODUCER_CONFIG",
+ {}).get(signal.event_type, {})
+ topic_config = event_type_producer_configs.get(topic, {})
+ topic_setting = topic_config.get('enabled', None)
+ set_custom_attribute(f'producer_config_setting_{topic}_{signal.event_type}',
+ topic_setting if topic_setting is not None else 'Unset')
+ return topic_setting
+
+
@receiver(CERTIFICATE_CREATED)
def listen_for_certificate_created_event(sender, signal, **kwargs): # pylint: disable=unused-argument
"""
Publish `CERTIFICATE_CREATED` events to the event bus.
"""
+ # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present
+ producer_config_setting = determine_producer_config_for_signal_and_topic(CERTIFICATE_CREATED,
+ 'learning-certificate-lifecycle')
+ if producer_config_setting is True:
+ log.info("Producing certificate-created event via config")
+ return
if SEND_CERTIFICATE_CREATED_SIGNAL.is_enabled():
+ log.info("Producing certificate-created event via manual send")
get_producer().send(
signal=CERTIFICATE_CREATED,
topic='learning-certificate-lifecycle',
@@ -181,7 +215,14 @@ def listen_for_certificate_revoked_event(sender, signal, **kwargs): # pylint: d
"""
Publish `CERTIFICATE_REVOKED` events to the event bus.
"""
+ # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present
+ producer_config_setting = determine_producer_config_for_signal_and_topic(CERTIFICATE_REVOKED,
+ 'learning-certificate-lifecycle')
+ if producer_config_setting is True:
+ log.info("Producing certificate-revoked event via config")
+ return
if SEND_CERTIFICATE_REVOKED_SIGNAL.is_enabled():
+ log.info("Producing certificate-revoked event via manual send")
get_producer().send(
signal=CERTIFICATE_REVOKED,
topic='learning-certificate-lifecycle',
diff --git a/lms/djangoapps/certificates/tests/test_handlers.py b/lms/djangoapps/certificates/tests/test_handlers.py
new file mode 100644
index 0000000000..241d9500bd
--- /dev/null
+++ b/lms/djangoapps/certificates/tests/test_handlers.py
@@ -0,0 +1,87 @@
+"""
+Unit tests for certificates signals
+"""
+from datetime import datetime, timezone
+from unittest import mock
+from uuid import uuid4
+
+from django.test import TestCase
+from opaque_keys.edx.keys import CourseKey, UsageKey
+from openedx_events.data import EventsMetadata
+from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData
+from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED
+
+from common.djangoapps.student.tests.factories import UserFactory
+from lms.djangoapps.certificates.handlers import handle_exam_attempt_rejected_event
+
+
+class ExamCompletionEventBusTests(TestCase):
+ """
+ Tests completion events from the event bus.
+ """
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course')
+ cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection'
+ cls.usage_key = UsageKey.from_string(cls.subsection_id)
+ cls.student_user = UserFactory(
+ username='student_user',
+ )
+
+ @staticmethod
+ def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None):
+ """ create ExamAttemptData object for exam based event """
+ if requesting_user:
+ requesting_user_data = UserData(
+ id=requesting_user.id,
+ is_active=True,
+ pii=None
+ )
+ else:
+ requesting_user_data = None
+
+ return ExamAttemptData(
+ student_user=UserData(
+ id=student_user.id,
+ is_active=True,
+ pii=UserPersonalData(
+ username=student_user.username,
+ email=student_user.email,
+ ),
+ ),
+ course_key=course_key,
+ usage_key=usage_key,
+ requesting_user=requesting_user_data,
+ exam_type=exam_type,
+ )
+
+ @staticmethod
+ def _get_exam_event_metadata(event_signal):
+ """ create metadata object for event """
+ return EventsMetadata(
+ event_type=event_signal.event_type,
+ id=uuid4(),
+ minorversion=0,
+ source='openedx/lms/web',
+ sourcehost='lms.test',
+ time=datetime.now(timezone.utc)
+ )
+
+ @mock.patch('lms.djangoapps.certificates.handlers.invalidate_certificate')
+ def test_exam_attempt_rejected_event(self, mock_api_function):
+ """
+ Assert that CertificateService api's invalidate_certificate is called upon consuming the event
+ """
+ exam_event_data = self._get_exam_event_data(self.student_user,
+ self.course_key,
+ self.usage_key,
+ exam_type='proctored')
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_REJECTED)
+
+ event_kwargs = {
+ 'exam_attempt': exam_event_data,
+ 'metadata': event_metadata
+ }
+ handle_exam_attempt_rejected_event(None, EXAM_ATTEMPT_REJECTED, **event_kwargs)
+ mock_api_function.assert_called_once_with(self.student_user.id, self.course_key, source='exam_event')
diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py
index 1fee639502..7949b84f13 100644
--- a/lms/djangoapps/certificates/tests/test_webview_views.py
+++ b/lms/djangoapps/certificates/tests/test_webview_views.py
@@ -20,12 +20,6 @@ from common.djangoapps.student.roles import CourseStaffRole
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.track.tests import EventTrackingTestCase
from common.djangoapps.util.date_utils import strftime_localized
-from lms.djangoapps.badges.events.course_complete import get_completion_badge
-from lms.djangoapps.badges.tests.factories import (
- BadgeAssertionFactory,
- BadgeClassFactory,
- CourseCompleteImageConfigurationFactory
-)
from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION
from lms.djangoapps.certificates.models import (
CertificateGenerationCourseSetting,
@@ -58,8 +52,6 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
-FEATURES_WITH_BADGES_ENABLED = FEATURES_WITH_CERTS_ENABLED.copy()
-FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True
FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy()
FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False
@@ -118,7 +110,6 @@ class CommonCertificatesTestCase(ModuleStoreTestCase):
)
CertificateHtmlViewConfigurationFactory.create()
LinkedInAddToProfileConfigurationFactory.create()
- CourseCompleteImageConfigurationFactory.create()
def _add_course_certificates(self, count=1, signatory_count=0, is_active=True):
"""
@@ -533,32 +524,7 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase)
self.assertContains(response, f'test_organization {self.course.number} Certificate |')
self.assertContains(response, 'logo_test1.png')
- @ddt.data(True, False)
- @patch('lms.djangoapps.certificates.views.webview.get_completion_badge')
- def test_fetch_badge_info(self, issue_badges, mock_get_completion_badge):
- """
- Test: Fetch badge class info if badges are enabled.
- """
- if issue_badges:
- features = FEATURES_WITH_BADGES_ENABLED
- else:
- features = FEATURES_WITH_CERTS_ENABLED
- with override_settings(FEATURES=features):
- badge_class = BadgeClassFactory(course_id=self.course_id, mode=self.cert.mode)
- mock_get_completion_badge.return_value = badge_class
-
- self._add_course_certificates(count=1, signatory_count=1, is_active=True)
- test_url = get_certificate_url(user_id=self.user.id, course_id=self.cert.course_id,
- uuid=self.cert.verify_uuid)
- response = self.client.get(test_url)
- assert response.status_code == 200
-
- if issue_badges:
- mock_get_completion_badge.assert_called()
- else:
- mock_get_completion_badge.assert_not_called()
-
- @override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED)
+ @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
@patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", {
"CERTIFICATE_TWITTER": True,
"CERTIFICATE_FACEBOOK": True,
@@ -589,10 +555,6 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase)
test_org = organizations_api.add_organization(organization_data=test_organization_data)
organizations_api.add_organization_course(organization_data=test_org, course_key=str(self.course.id))
self._add_course_certificates(count=1, signatory_count=1, is_active=True)
- badge_class = get_completion_badge(course_id=self.course_id, user=self.user)
- BadgeAssertionFactory.create(
- user=self.user, badge_class=badge_class,
- )
self.course.cert_html_view_overrides = {
"logo_src": "/static/certificates/images/course_override_logo.png"
}
@@ -629,8 +591,6 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase)
partner_long_name=long_org_name,
),
)
- # Test item from badge info
- self.assertContains(response, "Add to Mozilla Backpack")
# Test item from site configuration
self.assertContains(response, "https://www.test-site.org/about-us")
# Test course overrides
@@ -1784,53 +1744,3 @@ class CertificateEventTests(CommonCertificatesTestCase, EventTrackingTestCase):
},
actual_event['data']
)
-
- @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
- def test_evidence_event_sent(self):
- self._add_course_certificates(count=1, signatory_count=2)
-
- cert_url = get_certificate_url(
- user_id=self.user.id,
- course_id=self.course_id,
- uuid=self.cert.verify_uuid
- )
- test_url = f'{cert_url}?evidence_visit=1'
- self.recreate_tracker()
- badge_class = get_completion_badge(self.course_id, self.user)
- assertion = BadgeAssertionFactory.create(
- user=self.user, badge_class=badge_class,
- backend='DummyBackend',
- image_url='https://www.example.com/image.png',
- assertion_url='https://www.example.com/assertion.json',
- data={
- 'issuer': 'https://www.example.com/issuer.json',
- }
- )
- response = self.client.get(test_url)
-
- # There are two events being emitted in this flow.
- # One for page hit (due to the tracker in the middleware) and
- # one due to the certificate being visited.
- # We are interested in the second one.
- actual_event = self.get_event(1)
-
- assert response.status_code == 200
- assert_event_matches(
- {
- 'name': 'edx.badge.assertion.evidence_visited',
- 'data': {
- 'course_id': 'course-v1:testorg+run1+refundable_course',
- 'assertion_id': assertion.id,
- 'badge_generator': 'DummyBackend',
- 'badge_name': 'refundable course',
- 'issuing_component': '',
- 'badge_slug': 'course-v1testorgrun1refundable_course_honor_927f3ad',
- 'assertion_json_url': 'https://www.example.com/assertion.json',
- 'assertion_image_url': 'https://www.example.com/image.png',
- 'user_id': self.user.id,
- 'issuer': 'https://www.example.com/issuer.json',
- 'enrollment_mode': 'honor',
- },
- },
- actual_event
- )
diff --git a/lms/djangoapps/certificates/tests/tests.py b/lms/djangoapps/certificates/tests/tests.py
index ee3bd5c2b1..a6b6362792 100644
--- a/lms/djangoapps/certificates/tests/tests.py
+++ b/lms/djangoapps/certificates/tests/tests.py
@@ -12,9 +12,8 @@ from milestones.tests.utils import MilestonesTestCaseMixin
from pytz import UTC
from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
+from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.util.milestones_helpers import milestones_achieved_by_user, set_prerequisite_courses
-from lms.djangoapps.badges.tests.factories import CourseCompleteImageConfigurationFactory
from lms.djangoapps.certificates.api import certificate_info_for_user, certificate_status_for_student
from lms.djangoapps.certificates.models import (
CertificateStatuses,
@@ -204,20 +203,3 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin):
completed_milestones = milestones_achieved_by_user(student, str(pre_requisite_course.id))
assert len(completed_milestones) == 1
assert completed_milestones[0]['namespace'] == str(pre_requisite_course.id)
-
- @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
- @patch('lms.djangoapps.badges.backends.badgr.BadgrBackend', spec=True)
- def test_badge_callback(self, handler):
- student = UserFactory()
- course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True)
- CourseCompleteImageConfigurationFactory()
- CourseEnrollmentFactory(user=student, course_id=course.location.course_key, mode='honor')
- cert = GeneratedCertificateFactory.create(
- user=student,
- course_id=course.id,
- status=CertificateStatuses.generating,
- mode='verified'
- )
- cert.status = CertificateStatuses.downloadable
- cert.save()
- assert handler.return_value.award.called
diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py
index 4d99b7d772..28c7423948 100644
--- a/lms/djangoapps/certificates/views/webview.py
+++ b/lms/djangoapps/certificates/views/webview.py
@@ -15,7 +15,6 @@ from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.template import RequestContext
from django.utils import translation
from django.utils.encoding import smart_str
-from eventtracking import tracker
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from openedx_filters.learning.filters import CertificateRenderStarted
@@ -27,8 +26,6 @@ from common.djangoapps.edxmako.template import Template
from common.djangoapps.student.models import LinkedInAddToProfileConfiguration
from common.djangoapps.util.date_utils import strftime_localized
from common.djangoapps.util.views import handle_500
-from lms.djangoapps.badges.events.course_complete import get_completion_badge
-from lms.djangoapps.badges.utils import badges_enabled
from lms.djangoapps.certificates.api import (
certificates_viewable_for_course,
display_date_for_certificate,
@@ -396,42 +393,6 @@ def _track_certificate_events(request, course, user, user_certificate):
"""
Tracks web certificate view related events.
"""
- # Badge Request Event Tracking Logic
- course_key = course.location.course_key
-
- if 'evidence_visit' in request.GET:
- badge_class = get_completion_badge(course_key, user)
- if not badge_class:
- log.warning('Visit to evidence URL for badge, but badges not configured for course "%s"', course_key)
- badges = []
- else:
- badges = badge_class.get_for_user(user)
- if badges:
- # There should only ever be one of these.
- badge = badges[0]
- tracker.emit(
- 'edx.badge.assertion.evidence_visited',
- {
- 'badge_name': badge.badge_class.display_name,
- 'badge_slug': badge.badge_class.slug,
- 'badge_generator': badge.backend,
- 'issuing_component': badge.badge_class.issuing_component,
- 'user_id': user.id,
- 'course_id': str(course_key),
- 'enrollment_mode': badge.badge_class.mode,
- 'assertion_id': badge.id,
- 'assertion_image_url': badge.image_url,
- 'assertion_json_url': badge.assertion_url,
- 'issuer': badge.data.get('issuer'),
- }
- )
- else:
- log.warning(
- "Could not find badge for %s on course %s.",
- user.id,
- course_key,
- )
-
# track certificate evidence_visited event for analytics when certificate_user and accessing_user are different
if request.user and request.user.id != user.id:
emit_certificate_event('evidence_visited', user, str(course.id), event_data={
@@ -441,18 +402,6 @@ def _track_certificate_events(request, course, user, user_certificate):
})
-def _update_badge_context(context, course, user):
- """
- Updates context with badge info.
- """
- badge = None
- if badges_enabled() and course.issue_badges:
- badges = get_completion_badge(course.location.course_key, user).get_for_user(user)
- if badges:
- badge = badges[0]
- context['badge'] = badge
-
-
def _update_organization_context(context, course):
"""
Updates context with organization related info.
@@ -630,9 +579,6 @@ def render_html_view(request, course_id, certificate=None): # pylint: disable=t
# Append/Override the existing view context values with certificate specific values
_update_certificate_context(context, course, course_overview, user_certificate, platform_name)
- # Append badge info
- _update_badge_context(context, course, user)
-
# Add certificate header/footer data to current context
context.update(get_certificate_header_context(is_secure=request.is_secure()))
context.update(get_certificate_footer_context())
diff --git a/lms/djangoapps/commerce/api/v0/views.py b/lms/djangoapps/commerce/api/v0/views.py
index b0fa4fcc6e..1022173503 100644
--- a/lms/djangoapps/commerce/api/v0/views.py
+++ b/lms/djangoapps/commerce/api/v0/views.py
@@ -9,7 +9,6 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthenticat
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from requests.exceptions import HTTPError
-from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT
from rest_framework.views import APIView
@@ -164,7 +163,6 @@ class BasketOrderView(APIView):
Retrieve the order associated with a basket.
"""
- authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def get(self, request, *_args, **kwargs):
diff --git a/lms/djangoapps/commerce/api/v1/tests/test_views.py b/lms/djangoapps/commerce/api/v1/tests/test_views.py
index 47de5d8ff5..0d9557c31a 100644
--- a/lms/djangoapps/commerce/api/v1/tests/test_views.py
+++ b/lms/djangoapps/commerce/api/v1/tests/test_views.py
@@ -23,7 +23,7 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p
from ....tests.mocks import mock_order_endpoint
from ....tests.test_views import UserMixin
-PASSWORD = 'test'
+PASSWORD = 'Password1234'
JSON_CONTENT_TYPE = 'application/json'
diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py
index 9af2ee7d97..410d2520cc 100644
--- a/lms/djangoapps/commerce/tests/test_views.py
+++ b/lms/djangoapps/commerce/tests/test_views.py
@@ -3,6 +3,9 @@
from common.djangoapps.student.tests.factories import UserFactory
+TEST_PASSWORD = "Password1234"
+
+
class UserMixin:
""" Mixin for tests involving users. """
@@ -12,4 +15,4 @@ class UserMixin:
def _login(self):
""" Log into LMS. """
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=TEST_PASSWORD)
diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py
index 72e5e430f6..e2426708d6 100644
--- a/lms/djangoapps/course_api/blocks/tests/test_views.py
+++ b/lms/djangoapps/course_api/blocks/tests/test_views.py
@@ -60,7 +60,7 @@ class TestBlocksView(SharedModuleStoreTestCase):
self.admin_user = AdminFactory.create()
self.data_researcher = UserFactory.create()
CourseDataResearcherRole(self.course_key).add_users(self.data_researcher)
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
CourseEnrollmentFactory.create(user=self.user, course_id=self.course_key)
# default values for url and query_params
@@ -248,7 +248,7 @@ class TestBlocksView(SharedModuleStoreTestCase):
self.client.logout()
self.verify_response(403, cacheable=False)
# Verify response for a staff user.
- self.client.login(username=self.admin_user.username, password='test')
+ self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD)
self.verify_response(cacheable=False)
def test_non_existent_course(self):
@@ -269,7 +269,7 @@ class TestBlocksView(SharedModuleStoreTestCase):
self.verify_response(400)
def test_no_user_staff_all_blocks(self):
- self.client.login(username=self.admin_user.username, password='test')
+ self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD)
self.query_params.pop('username')
self.query_params['all_blocks'] = True
self.verify_response()
@@ -319,7 +319,7 @@ class TestBlocksView(SharedModuleStoreTestCase):
- other_course_settings
- course_visibility
"""
- self.client.login(username=self.admin_user.username, password='test')
+ self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD)
response = self.verify_response(params={
'all_blocks': True,
'requested_fields': ['other_course_settings', 'course_visibility'],
@@ -351,7 +351,7 @@ class TestBlocksView(SharedModuleStoreTestCase):
- other_course_settings
- course_visibility
"""
- self.client.login(username=self.admin_user.username, password='test')
+ self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD)
response = self.verify_response(params={
'all_blocks': True,
'requested_fields': ['course_visibility'],
@@ -370,7 +370,7 @@ class TestBlocksView(SharedModuleStoreTestCase):
"""
Test if data researcher has access to the api endpoint
"""
- self.client.login(username=self.data_researcher.username, password='test')
+ self.client.login(username=self.data_researcher.username, password=self.TEST_PASSWORD)
self.verify_response(params={
'all_blocks': True,
@@ -556,7 +556,7 @@ class TestBlockMetadataView(SharedModuleStoreTestCase): # pylint: disable=test-
def setUp(self):
super().setUp()
self.admin_user = AdminFactory.create()
- self.client.login(username=self.admin_user.username, password='test')
+ self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD)
self.usage_key = list(self.non_orphaned_block_usage_keys)[0]
self.url = reverse(
'blocks_metadata',
diff --git a/lms/djangoapps/course_api/tests/mixins.py b/lms/djangoapps/course_api/tests/mixins.py
index 2940826c68..46671867b7 100644
--- a/lms/djangoapps/course_api/tests/mixins.py
+++ b/lms/djangoapps/course_api/tests/mixins.py
@@ -8,7 +8,7 @@ from datetime import datetime
from common.djangoapps.student.tests.factories import UserFactory, CourseEnrollmentFactory, CourseAccessRoleFactory
from xmodule.modulestore.tests.factories import ToyCourseFactory # lint-amnesty, pylint: disable=wrong-import-order
-TEST_PASSWORD = 'edx'
+TEST_PASSWORD = 'Password1234'
class CourseApiFactoryMixin:
diff --git a/lms/djangoapps/course_blocks/transformers/tests/helpers.py b/lms/djangoapps/course_blocks/transformers/tests/helpers.py
index 27e7a686ab..fb78946280 100644
--- a/lms/djangoapps/course_blocks/transformers/tests/helpers.py
+++ b/lms/djangoapps/course_blocks/transformers/tests/helpers.py
@@ -50,7 +50,7 @@ class CourseStructureTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
"""
super().setUp()
# Set up users.
- self.password = 'test'
+ self.password = self.TEST_PASSWORD
self.user = UserFactory.create(password=self.password)
self.staff = UserFactory.create(password=self.password, is_staff=True)
@@ -253,7 +253,7 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase)
parent_block.children.append(self.xblock_keys[i])
update_block(parent_block)
- self.password = 'test'
+ self.password = self.TEST_PASSWORD
self.student = UserFactory.create(is_staff=False, username='test_student', password=self.password)
self.staff = UserFactory.create(is_staff=True, username='test_staff', password=self.password)
CourseEnrollmentFactory.create(
diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py
index 190cd76bf9..6bdc204434 100644
--- a/lms/djangoapps/course_home_api/progress/serializers.py
+++ b/lms/djangoapps/course_home_api/progress/serializers.py
@@ -145,3 +145,4 @@ class ProgressTabSerializer(VerifiedModeSerializer):
username = serializers.CharField()
user_has_passing_grade = serializers.BooleanField()
verification_data = VerificationDataSerializer()
+ disable_progress_graph = serializers.BooleanField()
diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py
index 8c21335dca..3783c19061 100644
--- a/lms/djangoapps/course_home_api/progress/views.py
+++ b/lms/djangoapps/course_home_api/progress/views.py
@@ -230,6 +230,7 @@ class ProgressTabView(RetrieveAPIView):
block = modulestore().get_course(course_key)
grading_policy = block.grading_policy
+ disable_progress_graph = block.disable_progress_graph
verification_status = IDVerificationService.user_status(student)
verification_link = None
if verification_status['status'] is None or verification_status['status'] == 'expired':
@@ -259,6 +260,7 @@ class ProgressTabView(RetrieveAPIView):
'username': username,
'user_has_passing_grade': user_has_passing_grade,
'verification_data': verification_data,
+ 'disable_progress_graph': disable_progress_graph,
}
context = self.get_serializer_context()
context['staff_access'] = is_staff
diff --git a/lms/djangoapps/course_wiki/middleware.py b/lms/djangoapps/course_wiki/middleware.py
index 54884a23f2..37bef0b747 100644
--- a/lms/djangoapps/course_wiki/middleware.py
+++ b/lms/djangoapps/course_wiki/middleware.py
@@ -15,6 +15,8 @@ from openedx.core.lib.request_utils import course_id_from_url
from openedx.features.enterprise_support.api import get_enterprise_consent_url
from common.djangoapps.student.models import CourseEnrollment
+from xmodule.modulestore.django import modulestore
+
class WikiAccessMiddleware(MiddlewareMixin):
"""
@@ -55,6 +57,20 @@ class WikiAccessMiddleware(MiddlewareMixin):
course_id = course_id_from_url(request.path)
wiki_path = request.path.partition('/wiki/')[2]
+ # if no wiki_path, can't get wiki_slug, so no point trying to look up
+ # course_id by wiki_slug
+ if not course_id and wiki_path:
+ # wiki path always starts with wiki_slug
+ wiki_slug = wiki_path.split('/')[0]
+
+ course_ids = modulestore().get_courses_for_wiki(wiki_slug)
+ # the above can return multiple courses, and to avoid ambiguity and
+ # avoid pointing to wrong courses, we only set course_id if we've
+ # got an exact match, i.e. only one course was returned for a
+ # wiki_slug
+ if len(course_ids) == 1:
+ course_id = course_ids[0]
+
if course_id:
# This is a /courses/org/name/run/wiki request
course_path = f"/courses/{str(course_id)}"
diff --git a/lms/djangoapps/course_wiki/tests/test_middleware.py b/lms/djangoapps/course_wiki/tests/test_middleware.py
index d9fa62608c..264a24f5a2 100644
--- a/lms/djangoapps/course_wiki/tests/test_middleware.py
+++ b/lms/djangoapps/course_wiki/tests/test_middleware.py
@@ -34,3 +34,10 @@ class TestWikiAccessMiddleware(ModuleStoreTestCase):
response = self.client.get('/courses/course-v1:edx+math101+2014/wiki/math101/')
self.assertContains(response, '/courses/course-v1:edx+math101+2014/wiki/math101/_edit/')
self.assertContains(response, '/courses/course-v1:edx+math101+2014/wiki/math101/_settings/')
+
+ def test_finds_course_by_wiki_slug(self):
+ """Test that finds course by wiki slug, if course id is not present in the url."""
+ response = self.client.get('/wiki/math101/')
+ request = response.wsgi_request
+ self.assertTrue(hasattr(request, 'course'))
+ self.assertEqual(request.course.id, self.course_math101.id)
diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py
index 8cdd93115d..7fc86947d4 100644
--- a/lms/djangoapps/course_wiki/tests/tests.py
+++ b/lms/djangoapps/course_wiki/tests/tests.py
@@ -24,7 +24,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas
# Create two accounts
self.student = 'view@test.com'
self.instructor = 'view2@test.com'
- self.password = 'foo'
+ self.password = self.TEST_PASSWORD
for username, email in [('u1', self.student), ('u2', self.instructor)]:
self.create_account(username, email, self.password)
self.activate_user(email)
diff --git a/lms/djangoapps/courseware/block_render.py b/lms/djangoapps/courseware/block_render.py
index 650b4418b4..fc02e2662e 100644
--- a/lms/djangoapps/courseware/block_render.py
+++ b/lms/djangoapps/courseware/block_render.py
@@ -40,8 +40,6 @@ from xblock.exceptions import NoSuchHandlerError, NoSuchViewError
from xblock.reference.plugins import FSService
from xblock.runtime import KvsFieldData
-from lms.djangoapps.badges.service import BadgingService
-from lms.djangoapps.badges.utils import badges_enabled
from lms.djangoapps.teams.services import TeamsService
from openedx.core.lib.xblock_services.call_to_action import CallToActionService
from xmodule.contentstore.django import contentstore
@@ -630,7 +628,6 @@ def prepare_runtime_for_user(
'partitions': PartitionService(course_id=course_id, cache=DEFAULT_REQUEST_CACHE.data),
'settings': SettingsService(),
'user_tags': UserTagsService(user=user, course_id=course_id),
- 'badging': BadgingService(course_id=course_id, modulestore=store) if badges_enabled() else None,
'teams': TeamsService(),
'teams_configuration': TeamsConfigurationService(),
'call_to_action': CallToActionService(),
diff --git a/lms/djangoapps/courseware/rules.py b/lms/djangoapps/courseware/rules.py
index 8202418f6f..07cbbab902 100644
--- a/lms/djangoapps/courseware/rules.py
+++ b/lms/djangoapps/courseware/rules.py
@@ -18,7 +18,7 @@ from xblock.core import XBlock
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.enrollments.api import is_enrollment_valid_for_proctoring
from common.djangoapps.student.models import CourseAccessRole
-from common.djangoapps.student.roles import CourseRole, OrgRole
+from common.djangoapps.student.roles import CourseRole, OrgRole, strict_role_checking
from xmodule.course_block import CourseBlock # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.error_block import ErrorBlock # lint-amnesty, pylint: disable=wrong-import-order
@@ -47,10 +47,14 @@ class HasAccessRule(Rule):
"""
A rule that calls `has_access` to determine whether it passes
"""
- def __init__(self, action):
+ def __init__(self, action, strict=False):
self.action = action
+ self.strict = strict
def check(self, user, instance=None):
+ if self.strict:
+ with strict_role_checking():
+ return has_access(user, self.action, instance)
return has_access(user, self.action, instance)
def query(self, user):
diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py
index 3a2ddbc456..1ab77ff93d 100644
--- a/lms/djangoapps/courseware/tests/helpers.py
+++ b/lms/djangoapps/courseware/tests/helpers.py
@@ -128,7 +128,7 @@ class BaseTestXmodule(ModuleStoreTestCase):
self.clients = {user.username: Client() for user in self.users}
self.login_statuses = [
self.clients[user.username].login(
- username=user.username, password='test')
+ username=user.username, password=self.TEST_PASSWORD)
for user in self.users
]
@@ -173,7 +173,7 @@ class LoginEnrollmentTestCase(TestCase):
Create a user account, activate, and log in.
"""
self.email = 'foo@test.com' # lint-amnesty, pylint: disable=attribute-defined-outside-init
- self.password = 'bar' # lint-amnesty, pylint: disable=attribute-defined-outside-init
+ self.password = 'Password1234' # lint-amnesty, pylint: disable=attribute-defined-outside-init
self.username = 'test' # lint-amnesty, pylint: disable=attribute-defined-outside-init
self.user = self.create_account(
self.username,
diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py
index f1f915fd02..d53d620d3e 100644
--- a/lms/djangoapps/courseware/tests/test_about.py
+++ b/lms/djangoapps/courseware/tests/test_about.py
@@ -284,7 +284,7 @@ class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, SharedModuleSt
# pylint: disable=attribute-defined-outside-init
# create a new account since the first account is already enrolled in the course
self.email = 'foo_second@test.com'
- self.password = 'bar'
+ self.password = 'Password1234'
self.username = 'test_second'
self.create_account(self.username, self.email, self.password)
self.activate_user(self.email)
diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py
index 8a4cbabad6..ba93ec6762 100644
--- a/lms/djangoapps/courseware/tests/test_access.py
+++ b/lms/djangoapps/courseware/tests/test_access.py
@@ -82,8 +82,8 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase)
super().setUp()
# Create ccx coach account
- self.coach = AdminFactory.create(password="test")
- self.client.login(username=self.coach.username, password="test")
+ self.coach = AdminFactory.create(password=self.TEST_PASSWORD)
+ self.client.login(username=self.coach.username, password=self.TEST_PASSWORD)
# assign role to coach
role = CourseCcxCoachRole(self.course.id)
@@ -152,7 +152,7 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase)
assert resp.status_code == 200
# Assert access of a student
- self.client.login(username=student.username, password='test')
+ self.client.login(username=student.username, password=self.TEST_PASSWORD)
resp = self.client.get(reverse('student_progress', args=[str(ccx_locator), self.coach.id]))
assert resp.status_code == 404
diff --git a/lms/djangoapps/courseware/tests/test_block_render.py b/lms/djangoapps/courseware/tests/test_block_render.py
index 80827afa63..668ce51712 100644
--- a/lms/djangoapps/courseware/tests/test_block_render.py
+++ b/lms/djangoapps/courseware/tests/test_block_render.py
@@ -75,8 +75,6 @@ from common.djangoapps.xblock_django.constants import (
ATTR_KEY_USER_IS_STAFF,
ATTR_KEY_USER_ROLE,
)
-from lms.djangoapps.badges.tests.factories import BadgeClassFactory
-from lms.djangoapps.badges.tests.test_models import get_image
from lms.djangoapps.courseware import block_render as render
from lms.djangoapps.courseware.access_response import AccessResponse
from lms.djangoapps.courseware.courses import get_course_info_section, get_course_with_access
@@ -362,7 +360,7 @@ class BlockRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
def test_session_authentication(self):
""" Test that the xblock endpoint supports session authentication."""
- self.client.login(username=self.mock_user.username, password="test")
+ self.client.login(username=self.mock_user.username, password=self.TEST_PASSWORD)
dispatch_url = self._get_dispatch_url()
response = self.client.post(dispatch_url)
assert 200 == response.status_code
@@ -387,7 +385,7 @@ class BlockRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test that sending POST request without or invalid position argument don't raise server error
"""
- self.client.login(username=self.mock_user.username, password="test")
+ self.client.login(username=self.mock_user.username, password=self.TEST_PASSWORD)
dispatch_url = self._get_dispatch_url()
response = self.client.post(dispatch_url)
assert 200 == response.status_code
@@ -2377,46 +2375,6 @@ class LMSXBlockServiceBindingTest(LMSXBlockServiceMixin):
self.block.runtime.service(self.block, 'user_tags').get_tag('fake_scope', key)
-@ddt.ddt
-class TestBadgingService(LMSXBlockServiceMixin):
- """Test the badging service interface"""
-
- @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
- def test_service_rendered(self):
- self._prepare_runtime()
- assert self.block.runtime.service(self.block, 'badging')
-
- def test_no_service_rendered(self):
- with pytest.raises(NoSuchServiceError):
- self.block.runtime.service(self.block, 'badging')
-
- @ddt.data(True, False)
- @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
- def test_course_badges_toggle(self, toggle):
- self.course = CourseFactory.create(metadata={'issue_badges': toggle})
- self._prepare_runtime()
- assert self.block.runtime.service(self.block, 'badging').course_badges_enabled is toggle
-
- @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
- def test_get_badge_class(self):
- self._prepare_runtime()
- badge_service = self.block.runtime.service(self.block, 'badging')
- premade_badge_class = BadgeClassFactory.create()
- # Ignore additional parameters. This class already exists.
- # We should get back the first class we created, rather than a new one.
- with get_image('good') as image_handle:
- badge_class = badge_service.get_badge_class(
- slug='test_slug', issuing_component='test_component', description='Attempted override',
- criteria='test', display_name='Testola', image_file_handle=image_handle
- )
- # These defaults are set on the factory.
- assert badge_class.criteria == 'https://example.com/syllabus'
- assert badge_class.display_name == 'Test Badge'
- assert badge_class.description == "Yay! It's a test badge."
- # File name won't always be the same.
- assert badge_class.image.path == premade_badge_class.image.path
-
-
class TestI18nService(LMSXBlockServiceMixin):
""" Test XBlockI18nService """
diff --git a/lms/djangoapps/courseware/tests/test_course_survey.py b/lms/djangoapps/courseware/tests/test_course_survey.py
index d729add1b6..98b5842cd2 100644
--- a/lms/djangoapps/courseware/tests/test_course_survey.py
+++ b/lms/djangoapps/courseware/tests/test_course_survey.py
@@ -22,7 +22,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe
"""
All tests for the views.py file
"""
- STUDENT_INFO = [('view@test.com', 'foo')]
+ STUDENT_INFO = [('view@test.com', 'Password1234')]
@classmethod
def setUpClass(cls):
diff --git a/lms/djangoapps/courseware/tests/test_discussion_xblock.py b/lms/djangoapps/courseware/tests/test_discussion_xblock.py
index bbe0e68bdc..599f125a7e 100644
--- a/lms/djangoapps/courseware/tests/test_discussion_xblock.py
+++ b/lms/djangoapps/courseware/tests/test_discussion_xblock.py
@@ -366,7 +366,7 @@ class TestXBlockInCourse(SharedModuleStoreTestCase):
"""
Tests that course block api returns student_view_data for discussion xblock
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse('blocks_in_block_tree', kwargs={'usage_key_string': str(self.course_usage_key)})
query_params = {
'depth': 'all',
diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py
index 240a9494fc..338a7bedfc 100644
--- a/lms/djangoapps/courseware/tests/test_entrance_exam.py
+++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py
@@ -136,7 +136,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
self.request = get_mock_request(UserFactory())
self.course = self.update_course(self.course, self.request.user.id)
- self.client.login(username=self.request.user.username, password="test")
+ self.client.login(username=self.request.user.username, password=self.TEST_PASSWORD)
CourseEnrollment.enroll(self.request.user, self.course.id)
self.expected_locked_toc = (
@@ -257,7 +257,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
# hit skip entrance exam api in instructor app
instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=instructor.username, password='test')
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, {
'unique_student_identifier': self.request.user.email,
@@ -277,7 +277,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest
self.client.logout()
staff_user = StaffFactory(course_key=self.course.id)
staff_user.is_staff = True
- self.client.login(username=staff_user.username, password='test')
+ self.client.login(username=staff_user.username, password=self.TEST_PASSWORD)
# assert staff has access to all toc
self.request.user = staff_user
diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py
index 43041b1437..9d211b05ea 100644
--- a/lms/djangoapps/courseware/tests/test_masquerade.py
+++ b/lms/djangoapps/courseware/tests/test_masquerade.py
@@ -88,7 +88,7 @@ class MasqueradeTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase, Mas
super().setUp()
self.test_user = self.create_user()
- self.login(self.test_user.email, 'test')
+ self.login(self.test_user.email, self.TEST_PASSWORD)
self.enroll(self.course, True)
def get_courseware_page(self):
@@ -298,12 +298,12 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi
def login_staff(self):
""" Login as a staff user """
self.logout()
- self.login(self.test_user.email, 'test')
+ self.login(self.test_user.email, self.TEST_PASSWORD)
def login_student(self):
""" Login as a student """
self.logout()
- self.login(self.student_user.email, 'test')
+ self.login(self.student_user.email, self.TEST_PASSWORD)
def submit_answer(self, response1, response2):
"""
@@ -351,7 +351,7 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi
student = UserFactory.create(username=username)
CourseEnrollment.enroll(student, self.course.id)
self.logout()
- self.login(student.email, 'test')
+ self.login(student.email, self.TEST_PASSWORD)
# Answer correctly as the student, and check progress.
self.submit_answer('Correct', 'Correct')
assert self.get_progress_detail() == '2/2'
@@ -378,7 +378,7 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi
# Verify the student state did not change.
self.logout()
- self.login(student.email, 'test')
+ self.login(student.email, self.TEST_PASSWORD)
assert self.get_progress_detail() == '2/2'
def test_masquerading_with_language_preference(self):
diff --git a/lms/djangoapps/courseware/tests/test_split_module.py b/lms/djangoapps/courseware/tests/test_split_module.py
index 7e6b88718e..5026f05ca2 100644
--- a/lms/djangoapps/courseware/tests/test_split_module.py
+++ b/lms/djangoapps/courseware/tests/test_split_module.py
@@ -59,7 +59,7 @@ class SplitTestBase(ModuleStoreTestCase):
self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
- self.client.login(username=self.student.username, password='test')
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
self.included_usage_keys = None
self.excluded_usage_keys = None
@@ -309,7 +309,7 @@ class SplitTestPosition(SharedModuleStoreTestCase):
self.student = UserFactory.create()
CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id)
- self.client.login(username=self.student.username, password='test')
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
def test_changing_position_works(self):
# Make a mock FieldDataCache for this course, so we can get the course block
diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py
index 8ffcea0298..4d55645d7a 100644
--- a/lms/djangoapps/courseware/tests/test_submitting_problems.py
+++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py
@@ -154,7 +154,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase, Probl
# create a test student
self.course = CourseFactory.create(display_name=self.COURSE_NAME, number=self.COURSE_SLUG)
self.student = 'view@test.com'
- self.password = 'foo'
+ self.password = self.TEST_PASSWORD
self.create_account('u1', self.student, self.password)
self.activate_user(self.student)
self.enroll(self.course)
diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py
index db59fa373b..6ad7ef73de 100644
--- a/lms/djangoapps/courseware/tests/test_tabs.py
+++ b/lms/djangoapps/courseware/tests/test_tabs.py
@@ -404,7 +404,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi
# login as instructor hit skip entrance exam api in instructor app
instructor = InstructorFactory(course_key=self.course.id)
self.client.logout()
- self.client.login(username=instructor.username, password='test')
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, {
@@ -426,7 +426,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi
# Login as member of staff
self.client.logout()
staff_user = StaffFactory(course_key=self.course.id)
- self.client.login(username=staff_user.username, password='test')
+ self.client.login(username=staff_user.username, password=self.TEST_PASSWORD)
course_tab_list = get_course_tab_list(staff_user, self.course)
assert len(course_tab_list) == 4
@@ -685,7 +685,7 @@ class CourseTabListTestCase(TabListTestCase):
# Login as member of staff
self.client.logout()
staff_user = StaffFactory(course_key=self.course.id)
- self.client.login(username=staff_user.username, password='test')
+ self.client.login(username=staff_user.username, password=self.TEST_PASSWORD)
course_tab_list_staff = get_course_tab_list(staff_user, self.course)
name_list_staff = [x.name for x in course_tab_list_staff]
assert 'Static Tab Free' in name_list_staff
diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py
index 42f2275e0f..67ada18e85 100644
--- a/lms/djangoapps/courseware/tests/test_view_authentication.py
+++ b/lms/djangoapps/courseware/tests/test_view_authentication.py
@@ -29,7 +29,7 @@ class TestViewAuth(EnterpriseTestConsentRequired, ModuleStoreTestCase, LoginEnro
Check that view authentication works properly.
"""
- ACCOUNT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')]
+ ACCOUNT_INFO = [('view@test.com', 'Password1234'), ('view2@test.com', 'Password1234')]
ENABLED_SIGNALS = ['course_published']
@staticmethod
@@ -111,7 +111,7 @@ class TestViewAuth(EnterpriseTestConsentRequired, ModuleStoreTestCase, LoginEnro
self.assert_request_status_code(302, url)
def login(self, user): # lint-amnesty, pylint: disable=arguments-differ
- return super().login(user.email, 'test')
+ return super().login(user.email, self.TEST_PASSWORD)
def setUp(self):
super().setUp()
diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py
index c7d7ce7b17..bebd4a79fb 100644
--- a/lms/djangoapps/courseware/tests/test_views.py
+++ b/lms/djangoapps/courseware/tests/test_views.py
@@ -30,6 +30,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
from pytz import UTC
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel
from rest_framework import status
+from rest_framework.test import APIClient
from web_fragments.fragment import Fragment
from xblock.core import XBlock
from xblock.fields import Scope, String
@@ -76,7 +77,10 @@ from lms.djangoapps.courseware.block_render import get_block, handle_xblock_call
from lms.djangoapps.courseware.tests.factories import StudentModuleFactory
from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin, get_expiration_banner_text, set_preview_mode
from lms.djangoapps.courseware.testutils import RenderXBlockTestMixin
-from lms.djangoapps.courseware.toggles import COURSEWARE_OPTIMIZED_RENDER_XBLOCK
+from lms.djangoapps.courseware.toggles import (
+ COURSEWARE_MICROFRONTEND_SEARCH_ENABLED,
+ COURSEWARE_OPTIMIZED_RENDER_XBLOCK,
+)
from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient
from lms.djangoapps.courseware.views.views import (
BasePublicVideoXBlockView,
@@ -712,7 +716,7 @@ class ViewsTestCase(BaseViewsTestCase):
# log into a staff account
admin = AdminFactory()
- assert self.client.login(username=admin.username, password='test')
+ assert self.client.login(username=admin.username, password=TEST_PASSWORD)
url = reverse('submission_history', kwargs={
'course_id': str(self.course_key),
@@ -727,7 +731,7 @@ class ViewsTestCase(BaseViewsTestCase):
# log into a staff account
admin = AdminFactory()
- assert self.client.login(username=admin.username, password='test')
+ assert self.client.login(username=admin.username, password=TEST_PASSWORD)
# try it with an existing user and a malicious location
url = reverse('submission_history', kwargs={
@@ -751,7 +755,7 @@ class ViewsTestCase(BaseViewsTestCase):
# log into a staff account
admin = AdminFactory.create()
- assert self.client.login(username=admin.username, password='test')
+ assert self.client.login(username=admin.username, password=TEST_PASSWORD)
usage_key = self.course_key.make_usage_key('problem', 'test-history')
state_client = DjangoXBlockUserStateClient(admin)
@@ -814,7 +818,7 @@ class ViewsTestCase(BaseViewsTestCase):
course_key = course.id
client = Client()
admin = AdminFactory.create()
- assert client.login(username=admin.username, password='test')
+ assert client.login(username=admin.username, password=TEST_PASSWORD)
state_client = DjangoXBlockUserStateClient(admin)
usage_key = course_key.make_usage_key('problem', 'test-history')
state_client.set(
@@ -1253,7 +1257,7 @@ class ProgressPageBaseTests(ModuleStoreTestCase):
def setUp(self):
super().setUp()
self.user = UserFactory.create()
- assert self.client.login(username=self.user.username, password='test')
+ assert self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.setup_course()
@@ -1352,7 +1356,7 @@ class ProgressPageTests(ProgressPageBaseTests):
# Create a new course, a user which will not be enrolled in course, admin user for staff access
course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split)
admin = AdminFactory.create()
- assert self.client.login(username=admin.username, password='test')
+ assert self.client.login(username=admin.username, password=TEST_PASSWORD)
# Create and enable Credit course
CreditCourse.objects.create(course_key=course.id, enabled=True)
@@ -1646,7 +1650,7 @@ class ProgressPageTests(ProgressPageBaseTests):
"""
CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
user = UserFactory.create()
- assert self.client.login(username=user.username, password='test')
+ assert self.client.login(username=user.username, password=TEST_PASSWORD)
add_course_mode(self.course, mode_slug=CourseMode.AUDIT)
add_course_mode(self.course)
CourseEnrollmentFactory(user=user, course_id=self.course.id, mode=course_mode)
@@ -1679,7 +1683,7 @@ class ProgressPageTests(ProgressPageBaseTests):
"""
CourseDurationLimitConfig.objects.create(enabled=False)
user = UserFactory.create()
- assert self.client.login(username=user.username, password='test')
+ assert self.client.login(username=user.username, password=TEST_PASSWORD)
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=course_mode
@@ -1698,7 +1702,7 @@ class ProgressPageTests(ProgressPageBaseTests):
in an ineligible mode.
"""
user = UserFactory.create()
- assert self.client.login(username=user.username, password='test')
+ assert self.client.login(username=user.username, password=TEST_PASSWORD)
CourseEnrollmentFactory(user=user, course_id=self.course.id, mode=course_mode)
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_create:
@@ -2081,7 +2085,7 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests):
self.setup_course(show_correctness=show_correctness, due_date=due_date, graded=graded)
self.add_problem()
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=TEST_PASSWORD)
resp = self._get_progress_page()
# Ensure that expected text is present
@@ -2133,7 +2137,7 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests):
self.add_problem()
# Login as a course staff user to view the student progress page.
- self.client.login(username=self.staff_user.username, password='test')
+ self.client.login(username=self.staff_user.username, password=TEST_PASSWORD)
resp = self._get_student_progress_page()
@@ -3198,7 +3202,7 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ModuleStoreTestCa
def setUp(self):
super().setUp()
self.user = UserFactory.create()
- assert self.client.login(username=self.user.username, password='test')
+ assert self.client.login(username=self.user.username, password=TEST_PASSWORD)
self.course = CourseFactory.create()
CourseOverview.load_from_module_store(self.course.id)
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
@@ -3298,7 +3302,7 @@ class PreviewTests(BaseViewsTestCase):
# Previews will not redirect to the mfe
course_staff = UserFactory.create(is_staff=False)
CourseStaffRole(self.course_key).add_users(course_staff)
- self.client.login(username=course_staff.username, password='test')
+ self.client.login(username=course_staff.username, password=TEST_PASSWORD)
assert self.client.get(preview_url).status_code == 200
@@ -3435,7 +3439,7 @@ class TestCourseWideResources(ModuleStoreTestCase):
CourseEnrollmentFactory(user=user, course_id=course.id)
if is_instructor:
allow_access(course, user, 'instructor')
- assert self.client.login(username=user.username, password='test')
+ assert self.client.login(username=user.username, password=TEST_PASSWORD)
kwargs = None
if param == 'course_id':
@@ -3683,3 +3687,41 @@ class TestPublicVideoXBlockEmbedView(TestBasePublicVideoXBlock):
assert template == 'public_video_share_embed.html'
assert context['fragment'] == fragment
assert context['course'] == self.course
+
+
+class TestCoursewareMFESearchAPI(SharedModuleStoreTestCase):
+ """
+ Tests the endpoint to fetch the Courseware Search waffle flag enabled status.
+ """
+
+ def setUp(self):
+ super().setUp()
+
+ self.course = CourseFactory.create()
+
+ self.client = APIClient()
+ self.apiUrl = reverse('courseware_search_enabled_view', kwargs={'course_id': str(self.course.id)})
+
+ @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=True)
+ def test_courseware_mfe_search_enabled(self):
+ """
+ Getter to check if user is allowed to use Courseware Search.
+ """
+
+ response = self.client.get(self.apiUrl, content_type='application/json')
+ body = json.loads(response.content.decode('utf-8'))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(body, {'enabled': True})
+
+ @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=False)
+ def test_is_mfe_search_disabled(self):
+ """
+ Getter to check if user is allowed to use Courseware Search.
+ """
+
+ response = self.client.get(self.apiUrl, content_type='application/json')
+ body = json.loads(response.content.decode('utf-8'))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(body, {'enabled': False})
diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py
index 3c2b946e91..a18074e9cc 100644
--- a/lms/djangoapps/courseware/testutils.py
+++ b/lms/djangoapps/courseware/testutils.py
@@ -81,7 +81,7 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta):
"""
Logs in the test user.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password='Password1234')
def course_options(self):
"""
diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py
index 3e13755123..78cecac7f5 100644
--- a/lms/djangoapps/courseware/toggles.py
+++ b/lms/djangoapps/courseware/toggles.py
@@ -55,6 +55,19 @@ COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION = CourseWaffleFl
f'{WAFFLE_FLAG_NAMESPACE}.mfe_progress_milestones_streak_celebration', __name__
)
+# .. toggle_name: courseware.mfe_courseware_search
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Enables Courseware Search on Learning MFE
+# .. toggle_use_cases: temporary
+# .. toggle_creation_date: 2023-09-28
+# .. toggle_target_removal_date: None
+# .. toggle_tickets: KBK-20
+# .. toggle_warning: None.
+COURSEWARE_MICROFRONTEND_SEARCH_ENABLED = CourseWaffleFlag(
+ f'{WAFFLE_FLAG_NAMESPACE}.mfe_courseware_search', __name__
+)
+
# .. toggle_name: courseware.mfe_progress_milestones_streak_discount_enabled
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
@@ -153,3 +166,10 @@ def course_is_invitation_only(courselike) -> bool:
def learning_assistant_is_active(course_key):
return COURSEWARE_LEARNING_ASSISTANT.is_enabled(course_key)
+
+
+def courseware_mfe_search_is_enabled(course_key=None):
+ """
+ Return whether the courseware.mfe_courseware_search flag is on.
+ """
+ return COURSEWARE_MICROFRONTEND_SEARCH_ENABLED.is_enabled(course_key)
diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py
index 085e9e5a19..fdc90a46a5 100644
--- a/lms/djangoapps/courseware/views/views.py
+++ b/lms/djangoapps/courseware/views/views.py
@@ -18,8 +18,8 @@ from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pyli
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Q, prefetch_related_objects
-from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.shortcuts import redirect
+from django.http import JsonResponse, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
from django.template.context_processors import csrf
from django.urls import reverse
from django.utils.decorators import method_decorator
@@ -38,8 +38,8 @@ from markupsafe import escape
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx_filters.learning.filters import CourseAboutRenderStarted
-from pytz import UTC
from requests.exceptions import ConnectionError, Timeout # pylint: disable=redefined-builtin
+from pytz import UTC
from rest_framework import status
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
@@ -87,7 +87,7 @@ from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_stu
from lms.djangoapps.courseware.model_data import FieldDataCache
from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule
from lms.djangoapps.courseware.permissions import MASQUERADE_AS_STUDENT, VIEW_COURSE_HOME, VIEW_COURSEWARE
-from lms.djangoapps.courseware.toggles import course_is_invitation_only
+from lms.djangoapps.courseware.toggles import course_is_invitation_only, courseware_mfe_search_is_enabled
from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient
from lms.djangoapps.courseware.utils import (
_use_new_financial_assistance_flow,
@@ -2275,3 +2275,15 @@ def get_learner_username(learner_identifier):
learner = User.objects.filter(Q(username=learner_identifier) | Q(email=learner_identifier)).first()
if learner:
return learner.username
+
+
+@api_view(['GET'])
+def courseware_mfe_search_enabled(request, course_id=None):
+ """
+ Simple GET endpoint to expose whether the course may use Courseware Search.
+ """
+
+ course_key = CourseKey.from_string(course_id) if course_id else None
+
+ payload = {"enabled": courseware_mfe_search_is_enabled(course_key)}
+ return JsonResponse(payload)
diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py
index d450998a60..b871d4af44 100644
--- a/lms/djangoapps/discussion/django_comment_client/base/tests.py
+++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py
@@ -241,7 +241,7 @@ class ViewsTestCaseMixin:
with patch('common.djangoapps.student.models.user.cc.User.save'):
uname = 'student'
email = 'student@edx.org'
- self.password = 'test'
+ self.password = 'Password1234'
# Create the user and make them active so we can log them in.
self.student = UserFactory.create(username=uname, email=email, password=self.password)
@@ -464,7 +464,7 @@ class ViewsTestCase(
with patch('common.djangoapps.student.models.user.cc.User.save'):
uname = 'student'
email = 'student@edx.org'
- self.password = 'test'
+ self.password = 'Password1234'
# Create the user and make them active so we can log them in.
self.student = UserFactory.create(username=uname, email=email, password=self.password)
@@ -1859,6 +1859,34 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque
assert event['undo_vote'] == undo
assert event['vote_value'] == 'up'
+ @ddt.data('follow_thread', 'unfollow_thread',)
+ @patch('eventtracking.tracker.emit')
+ @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True)
+ def test_thread_followed_event(self, view_name, mock_request, mock_emit):
+ self._set_mock_request_data(mock_request, {
+ 'closed': False,
+ 'commentable_id': 'test_commentable_id',
+ 'username': 'test_user',
+ })
+ request = RequestFactory().post('dummy_url', {})
+ request.user = self.student
+ request.view_name = view_name
+ view_function = getattr(views, view_name)
+ kwargs = dict(course_id=str(self.course.id))
+ kwargs['thread_id'] = 'thread_id'
+ view_function(request, **kwargs)
+
+ assert mock_emit.called
+ event_name, event_data = mock_emit.call_args[0]
+ action_name = 'followed' if view_name == 'follow_thread' else 'unfollowed'
+ expected_action_value = True if view_name == 'follow_thread' else False
+ assert event_name == f'edx.forum.thread.{action_name}'
+ assert event_data['commentable_id'] == 'test_commentable_id'
+ assert event_data['id'] == 'thread_id'
+ assert event_data['followed'] == expected_action_value
+ assert event_data['user_forums_roles'] == ['Student']
+ assert event_data['user_course_roles'] == ['Wizard']
+
class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRequestSetupMixin):
diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py
index 5ad3dae899..1ec41e3155 100644
--- a/lms/djangoapps/discussion/django_comment_client/base/views.py
+++ b/lms/djangoapps/discussion/django_comment_client/base/views.py
@@ -134,6 +134,20 @@ def track_thread_created_event(request, course, thread, followed, from_mfe_sideb
track_created_event(request, event_name, course, thread, event_data)
+def track_thread_followed_event(request, course, thread, followed):
+ """
+ Send analytics event for a newly followed/unfollowed thread.
+ """
+ action_name = 'followed' if followed else 'unfollowed'
+ event_name = _EVENT_NAME_TEMPLATE.format(obj_type='thread', action_name=action_name)
+ event_data = {
+ 'commentable_id': thread.commentable_id,
+ 'id': thread.id,
+ 'followed': followed,
+ }
+ track_forum_event(request, event_name, course, thread, event_data)
+
+
def track_comment_created_event(request, course, comment, commentable_id, followed, from_mfe_sidebar=False):
"""
Send analytics event for a newly created response or comment.
@@ -938,9 +952,12 @@ def un_pin_thread(request, course_id, thread_id):
@permitted
def follow_thread(request, course_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument
user = cc.User.from_django_user(request.user)
+ course_key = CourseKey.from_string(course_id)
+ course = get_course_by_id(course_key)
thread = cc.Thread.find(thread_id)
user.follow(thread)
thread_followed.send(sender=None, user=request.user, post=thread)
+ track_thread_followed_event(request, course, thread, True)
return JsonResponse({})
@@ -966,10 +983,13 @@ def unfollow_thread(request, course_id, thread_id): # lint-amnesty, pylint: dis
given a course id and thread id, stop following this thread
ajax only
"""
+ course_key = CourseKey.from_string(course_id)
+ course = get_course_by_id(course_key)
user = cc.User.from_django_user(request.user)
thread = cc.Thread.find(thread_id)
user.unfollow(thread)
thread_unfollowed.send(sender=None, user=request.user, post=thread)
+ track_thread_followed_event(request, course, thread, False)
return JsonResponse({})
diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py
index 2caa901d51..54878d69ca 100644
--- a/lms/djangoapps/discussion/rest_api/api.py
+++ b/lms/djangoapps/discussion/rest_api/api.py
@@ -93,7 +93,7 @@ from ..django_comment_client.base.views import (
track_voted_event,
track_discussion_reported_event,
track_discussion_unreported_event,
- track_forum_search_event
+ track_forum_search_event, track_thread_followed_event
)
from ..django_comment_client.utils import (
get_group_id_for_user,
@@ -127,8 +127,8 @@ from .utils import (
discussion_open_for_user,
get_usernames_for_course,
get_usernames_from_search_string,
- is_posting_allowed,
set_attribute,
+ is_posting_allowed
)
@@ -1333,7 +1333,7 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con
if field in request_fields and field in api_content and form_value != api_content[field]:
api_content[field] = form_value
if field == "following":
- _handle_following_field(form_value, context["cc_requester"], cc_content)
+ _handle_following_field(form_value, context["cc_requester"], cc_content, request)
elif field == "abuse_flagged":
_handle_abuse_flagged_field(form_value, context["cc_requester"], cc_content, request)
elif field == "voted":
@@ -1346,12 +1346,15 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con
raise ValidationError({field: ["Invalid Key"]})
-def _handle_following_field(form_value, user, cc_content):
+def _handle_following_field(form_value, user, cc_content, request):
"""follow/unfollow thread for the user"""
+ course_key = CourseKey.from_string(cc_content.course_id)
+ course = get_course_with_access(request.user, 'load', course_key)
if form_value:
user.follow(cc_content)
else:
user.unfollow(cc_content)
+ track_thread_followed_event(request, course, cc_content, form_value)
def _handle_abuse_flagged_field(form_value, user, cc_content, request):
diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
new file mode 100644
index 0000000000..f3a5e3d61d
--- /dev/null
+++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
@@ -0,0 +1,260 @@
+"""
+Discussion notifications sender util.
+"""
+from django.conf import settings
+from lms.djangoapps.discussion.django_comment_client.permissions import get_team
+from openedx_events.learning.data import UserNotificationData
+from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
+
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
+from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings, CourseUserGroup
+from openedx.core.djangoapps.discussions.utils import get_divided_discussions
+from django.utils.translation import gettext_lazy as _
+
+from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
+from openedx.core.djangoapps.django_comment_common.comment_client.subscriptions import Subscription
+from openedx.core.djangoapps.django_comment_common.models import (
+ FORUM_ROLE_ADMINISTRATOR,
+ FORUM_ROLE_COMMUNITY_TA,
+ FORUM_ROLE_MODERATOR,
+ CourseDiscussionSettings,
+ Role
+)
+
+
+class DiscussionNotificationSender:
+ """
+ Class to send notifications to users who are subscribed to the thread.
+ """
+
+ def __init__(self, thread, course, creator, parent_id=None):
+ self.thread = thread
+ self.course = course
+ self.creator = creator
+ self.parent_id = parent_id
+ self.parent_response = None
+ self._get_parent_response()
+
+ def _send_notification(self, user_ids, notification_type, extra_context=None):
+ """
+ Send notification to users
+ """
+ if not user_ids:
+ return
+
+ if extra_context is None:
+ extra_context = {}
+
+ notification_data = UserNotificationData(
+ user_ids=[int(user_id) for user_id in user_ids],
+ context={
+ "replier_name": self.creator.username,
+ "post_title": self.thread.title,
+ "course_name": self.course.display_name,
+ **extra_context,
+ },
+ notification_type=notification_type,
+ content_url=f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/{self.thread.id}",
+ app_name="discussion",
+ course_key=self.course.id,
+ )
+ USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data)
+
+ def _get_parent_response(self):
+ """
+ Get parent response object
+ """
+ if self.parent_id and not self.parent_response:
+ self.parent_response = Comment(id=self.parent_id).retrieve()
+
+ return self.parent_response
+
+ def send_new_response_notification(self):
+ """
+ Send notification to users who are subscribed to the main thread/post i.e.
+ there is a response to the main thread.
+ """
+ if not self.parent_id and self.creator.id != int(self.thread.user_id):
+ self._send_notification([self.thread.user_id], "new_response")
+
+ def _response_and_thread_has_same_creator(self) -> bool:
+ """
+ Check if response and main thread have same author.
+ """
+ return int(self.parent_response.user_id) == int(self.thread.user_id)
+
+ def _response_and_comment_has_same_creator(self):
+ return int(self.parent_response.attributes['user_id']) == self.creator.id
+
+ def send_new_comment_notification(self):
+ """
+ Send notification to parent thread creator i.e. comment on the response.
+ """
+ if (
+ self.parent_response and
+ self.creator.id != int(self.thread.user_id)
+ ):
+ # use your if author of response is same as author of post.
+ # use 'their' if comment author is also response author.
+ author_name = (
+ # Translators: Replier commented on "your" response to your post
+ _("your")
+ if self._response_and_thread_has_same_creator()
+ else (
+ # Translators: Replier commented on "their" response to your post
+ _("their")
+ if self._response_and_comment_has_same_creator()
+ else f"{self.parent_response.username}'s"
+ )
+ )
+ context = {
+ "author_name": str(author_name),
+ }
+ self._send_notification([self.thread.user_id], "new_comment", extra_context=context)
+
+ def send_new_comment_on_response_notification(self):
+ """
+ Send notification to parent response creator i.e. comment on the response.
+ Do not send notification if author of response is same as author of post.
+ """
+ if (
+ self.parent_response and
+ self.creator.id != int(self.parent_response.user_id) and not
+ self._response_and_thread_has_same_creator()
+ ):
+ self._send_notification([self.parent_response.user_id], "new_comment_on_response")
+
+ def _check_if_subscriber_is_not_thread_or_content_creator(self, subscriber_id) -> bool:
+ """
+ Check if the subscriber is not the thread creator or response creator
+ """
+ is_not_creator = (
+ subscriber_id != int(self.thread.user_id) and
+ subscriber_id != int(self.creator.id)
+ )
+ if self.parent_response:
+ return is_not_creator and subscriber_id != int(self.parent_response.user_id)
+
+ return is_not_creator
+
+ def send_response_on_followed_post_notification(self):
+ """
+ Send notification to followers of the thread/post
+ except:
+ Tread creator , response creator,
+ """
+ users = []
+ page = 1
+ has_more_subscribers = True
+
+ while has_more_subscribers:
+
+ subscribers = Subscription.fetch(self.thread.id, query_params={'page': page})
+ if page <= subscribers.num_pages:
+ for subscriber in subscribers.collection:
+ # Check if the subscriber is not the thread creator or response creator
+ subscriber_id = int(subscriber.get('subscriber_id'))
+ # do not send notification to the user who created the response and the thread
+ if self._check_if_subscriber_is_not_thread_or_content_creator(subscriber_id):
+ users.append(subscriber_id)
+ else:
+ has_more_subscribers = False
+ page += 1
+ # Remove duplicate users from the list of users to send notification
+ users = list(set(users))
+ if not self.parent_id:
+ self._send_notification(users, "response_on_followed_post")
+ else:
+ self._send_notification(
+ users,
+ "comment_on_followed_post",
+ extra_context={"author_name": self.parent_response.username}
+ )
+
+ def _create_cohort_course_audience(self):
+ """
+ Creates audience based on user cohort and role
+ """
+ course_key_str = str(self.course.id)
+ discussion_cohorted = is_discussion_cohorted(course_key_str)
+
+ # Retrieves cohort divided discussion
+ discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
+ divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
+ self.course,
+ discussion_settings
+ )
+
+ # Checks if post has any cohort assigned
+ group_id = self.thread.attributes['group_id']
+ if group_id is not None:
+ group_id = int(group_id)
+
+ # Course wide topics
+ topic_id = self.thread.attributes['commentable_id']
+ all_topics = divided_inline_discussions + divided_course_wide_discussions
+ topic_divided = topic_id in all_topics or discussion_settings.always_divide_inline_discussions
+
+ # Team object from topic id
+ team = get_team(topic_id)
+
+ user_ids = []
+ if team:
+ user_ids = team.users.all().values_list('id', flat=True)
+ elif discussion_cohorted and topic_divided and group_id is not None:
+ users_in_cohort = CourseUserGroup.objects.filter(
+ course_id=course_key_str, id=group_id
+ ).values_list('users__id', flat=True)
+ user_ids.extend(users_in_cohort)
+
+ privileged_roles = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
+ privileged_users = Role.objects.filter(
+ name__in=privileged_roles,
+ course_id=course_key_str
+ ).values_list('users__id', flat=True)
+ user_ids.extend(privileged_users)
+
+ staff_users = CourseStaffRole(self.course.id).users_with_role().values_list('id', flat=True)
+ user_ids.extend(staff_users)
+
+ admin_users = CourseInstructorRole(self.course.id).users_with_role().values_list('id', flat=True)
+ user_ids.extend(admin_users)
+ else:
+ user_ids = CourseEnrollment.objects.filter(
+ course__id=course_key_str, is_active=True
+ ).values_list('user__id', flat=True)
+
+ unique_user_ids = list(set(user_ids))
+ if self.creator.id in unique_user_ids:
+ unique_user_ids.remove(self.creator.id)
+ return unique_user_ids
+
+ def send_new_thread_created_notification(self):
+ """
+ Send notification based on notification_type
+ """
+ thread_type = self.thread.attributes['thread_type']
+ notification_type = (
+ "new_question_post"
+ if thread_type == "question"
+ else ("new_discussion_post" if thread_type == "discussion" else "")
+ )
+ if notification_type not in ['new_discussion_post', 'new_question_post']:
+ raise ValueError(f'Invalid notification type {notification_type}')
+
+ user_ids = self._create_cohort_course_audience()
+ context = {
+ 'username': self.creator.username,
+ 'post_title': self.thread.title
+ }
+ self._send_notification(user_ids, notification_type, context)
+
+
+def is_discussion_cohorted(course_key_str):
+ """
+ Returns if the discussion is divided by cohorts
+ """
+ cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str)
+ discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
+ return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions
diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py
index 85de47286f..f5bb20c3d8 100644
--- a/lms/djangoapps/discussion/rest_api/tasks.py
+++ b/lms/djangoapps/discussion/rest_api/tasks.py
@@ -8,7 +8,7 @@ from opaque_keys.edx.locator import CourseKey
from lms.djangoapps.courseware.courses import get_course_with_access
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
-from lms.djangoapps.discussion.rest_api.utils import DiscussionNotificationSender
+from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender
User = get_user_model()
@@ -46,3 +46,4 @@ def send_response_notifications(thread_id, course_key_str, user_id, parent_id=No
notification_sender.send_new_comment_notification()
notification_sender.send_new_response_notification()
notification_sender.send_new_comment_on_response_notification()
+ notification_sender.send_response_on_followed_post_notification()
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py
index 34e323c723..98c27feb58 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_api.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py
@@ -2156,6 +2156,7 @@ class CreateThreadTest(
@disable_signal(api, 'comment_created')
@disable_signal(api, 'comment_voted')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock())
class CreateCommentTest(
ForumsEnableMixin,
CommentsServiceMockMixin,
@@ -2194,6 +2195,17 @@ class CreateCommentTest(
"raw_body": "Test body",
}
+ mock_response = {
+ 'collection': [],
+ 'page': 1,
+ 'num_pages': 1,
+ 'subscriptions_count': 1,
+ 'corrected_text': None
+
+ }
+ self.register_get_subscriptions('cohort_thread', mock_response)
+ self.register_get_subscriptions('test_thread', mock_response)
+
@ddt.data(None, "test_parent")
@mock.patch("eventtracking.tracker.emit")
def test_success(self, parent_id, mock_emit):
@@ -2697,7 +2709,8 @@ class UpdateThreadTest(
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
- def test_following(self, old_following, new_following):
+ @mock.patch("eventtracking.tracker.emit")
+ def test_following(self, old_following, new_following, mock_emit):
"""
Test attempts to edit the "following" field.
@@ -2727,6 +2740,13 @@ class UpdateThreadTest(
)
request_data.pop("request_id", None)
assert request_data == {'source_type': ['thread'], 'source_id': ['test_thread']}
+ event_name, event_data = mock_emit.call_args[0]
+ expected_event_action = 'followed' if new_following else 'unfollowed'
+ assert event_name == f'edx.forum.thread.{expected_event_action}'
+ assert event_data['commentable_id'] == 'original_topic'
+ assert event_data['id'] == 'test_thread'
+ assert event_data['followed'] == new_following
+ assert event_data['user_forums_roles'] == ['Student']
@ddt.data(*itertools.product([True, False], [True, False]))
@ddt.unpack
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
index e7bc03c0ad..896e237f5f 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py
@@ -2,11 +2,14 @@
Test cases for tasks.py
"""
from unittest import mock
-from django.conf import settings
-from edx_toggles.toggles.testutils import override_waffle_flag
+from unittest.mock import Mock
import ddt
import httpretty
+from django.conf import settings
+from edx_toggles.toggles.testutils import override_waffle_flag
+from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
+
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import StaffFactory, UserFactory
from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
@@ -14,18 +17,19 @@ from lms.djangoapps.discussion.rest_api.tasks import send_response_notifications
from lms.djangoapps.discussion.rest_api.tests.utils import ThreadMock, make_minimal_cs_thread
from openedx.core.djangoapps.course_groups.models import CohortMembership, CourseCohortsSettings
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
+from openedx.core.djangoapps.discussions.models import DiscussionTopicLink
from openedx.core.djangoapps.django_comment_common.models import (
- CourseDiscussionSettings,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_MODERATOR,
FORUM_ROLE_STUDENT,
+ CourseDiscussionSettings
)
-from openedx.core.djangoapps.discussions.models import DiscussionTopicLink
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
-from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
+
+from ..discussions_notifications import DiscussionNotificationSender
from .test_views import DiscussionAPIViewTestMixin
@@ -241,6 +245,7 @@ class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTe
self.assert_users_id_list(user_ids_list, handler.call_args[1]['notification_data'].user_ids)
+@ddt.ddt
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""
@@ -272,6 +277,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
"thread_type": 'discussion',
"title": thread.title,
})
+ self._register_subscriptions_endpoint()
def test_basic(self):
"""
@@ -293,8 +299,8 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
# Post the form or do what it takes to send the signal
send_response_notifications(self.thread.id, str(self.course.id), self.user_2.id, parent_id=None)
- self.assertEqual(handler.call_count, 1)
- args = handler.call_args[1]['notification_data']
+ self.assertEqual(handler.call_count, 2)
+ args = handler.call_args_list[0][1]['notification_data']
self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id])
self.assertEqual(args.notification_type, 'new_response')
expected_context = {
@@ -363,13 +369,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
def test_no_signal_on_creators_own_thread(self):
"""
- Makes sure that no signal is emitted if user creates response on
+ Makes sure that 1 signal is emitted if user creates response on
their own thread.
"""
handler = mock.Mock()
USER_NOTIFICATION_REQUESTED.connect(handler)
send_response_notifications(self.thread.id, str(self.course.id), self.user_1.id, parent_id=None)
- self.assertEqual(handler.call_count, 0)
+ self.assertEqual(handler.call_count, 1)
def test_comment_creators_own_response(self):
"""
@@ -387,7 +393,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id)
# check if 1 call is made to the handler i.e. for the thread creator
- self.assertEqual(handler.call_count, 1)
+ self.assertEqual(handler.call_count, 2)
# check if the notification is sent to the thread creator
args_comment = handler.call_args_list[0][1]['notification_data']
@@ -406,6 +412,71 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC
)
self.assertEqual(args_comment.app_name, 'discussion')
+ @ddt.data(
+ (None, 'response_on_followed_post'), (1, 'comment_on_followed_post')
+ )
+ @ddt.unpack
+ def test_send_notification_to_followers(self, parent_id, notification_type):
+ """
+ Test that the notification is sent to the followers of the thread
+ """
+ self.register_get_comment_response({
+ 'id': self.thread.id,
+ 'thread_id': self.thread.id,
+ 'user_id': self.thread.user_id
+ })
+ handler = Mock()
+ USER_NOTIFICATION_REQUESTED.connect(handler)
+
+ # Post the form or do what it takes to send the signal
+ notification_sender = DiscussionNotificationSender(self.thread, self.course, self.user_2, parent_id=parent_id)
+ notification_sender.send_response_on_followed_post_notification()
+ self.assertEqual(handler.call_count, 1)
+ args = handler.call_args[1]['notification_data']
+ # only sent to user_3 because user_2 is the one who created the response
+ self.assertEqual([self.user_3.id], args.user_ids)
+ self.assertEqual(args.notification_type, notification_type)
+ expected_context = {
+ 'replier_name': self.user_2.username,
+ 'post_title': 'test thread',
+ 'course_name': self.course.display_name,
+ }
+ if parent_id:
+ expected_context['author_name'] = 'dummy'
+ self.assertDictEqual(args.context, expected_context)
+ self.assertEqual(
+ args.content_url,
+ _get_mfe_url(self.course.id, self.thread.id)
+ )
+ self.assertEqual(args.app_name, 'discussion')
+
+ def _register_subscriptions_endpoint(self):
+ """
+ Registers the endpoint for the subscriptions API
+ """
+ mock_response = {
+ 'collection': [
+ {
+ '_id': 1,
+ 'subscriber_id': str(self.user_2.id),
+ "source_id": self.thread.id,
+ "source_type": "thread",
+ },
+ {
+ '_id': 2,
+ 'subscriber_id': str(self.user_3.id),
+ "source_id": self.thread.id,
+ "source_type": "thread",
+ },
+ ],
+ 'page': 1,
+ 'num_pages': 1,
+ 'subscriptions_count': 2,
+ 'corrected_text': None
+
+ }
+ self.register_get_subscriptions(self.thread.id, mock_response)
+
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
@@ -456,6 +527,7 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas
'thread_id': thread.id,
'user_id': response.user_id
})
+ self.register_get_subscriptions(1, {})
send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id)
handler.assert_called_once()
context = handler.call_args[1]['notification_data'].context
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_utils.py b/lms/djangoapps/discussion/rest_api/tests/test_utils.py
index a0e7790cba..db24847a82 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_utils.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_utils.py
@@ -2,29 +2,29 @@
Tests for Discussion REST API utils.
"""
+import unittest
from datetime import datetime, timedelta
import ddt
from pytz import UTC
-import unittest
-from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole
-from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
-from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin
-from openedx.core.djangoapps.discussions.models import PostingRestriction, DiscussionsConfiguration
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
-from xmodule.modulestore.tests.factories import CourseFactory
+from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
+from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin
+from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin
from lms.djangoapps.discussion.rest_api.utils import (
discussion_open_for_user,
- get_course_ta_users_list,
- get_course_staff_users_list,
- get_moderator_users_list,
get_archived_topics,
- remove_empty_sequentials,
- is_posting_allowed
+ get_course_staff_users_list,
+ get_course_ta_users_list,
+ get_moderator_users_list,
+ is_posting_allowed,
+ remove_empty_sequentials
)
+from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
class DiscussionAPIUtilsTestCase(ModuleStoreTestCase):
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py
index 0e303cc240..7f6fa57725 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_views.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py
@@ -87,7 +87,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, Ur
start=datetime.now(UTC),
discussion_topics={"Test Topic": {"id": "test_topic"}}
)
- self.password = "password"
+ self.password = "Password1234"
self.user = UserFactory.create(password=self.password)
# Ensure that parental controls don't apply to this user
self.user.profile.year_of_birth = 1970
@@ -168,7 +168,7 @@ class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMi
content_type="image/jpeg",
),
}
- self.user = UserFactory.create(password="password")
+ self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC))
self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)})
@@ -176,7 +176,7 @@ class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMi
"""
Authenticates the test client with the example user.
"""
- self.client.login(username=self.user.username, password="password")
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
def enroll_user_in_course(self):
"""
@@ -320,10 +320,10 @@ class CommentViewSetListByUserTest(
self.addCleanup(httpretty.reset)
self.addCleanup(httpretty.disable)
- self.user = UserFactory.create(password="password")
+ self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.register_get_user_response(self.user)
- self.other_user = UserFactory.create(password="password")
+ self.other_user = UserFactory.create(password=self.TEST_PASSWORD)
self.register_get_user_response(self.other_user)
self.course = CourseFactory.create(org="a", course="b", run="c", start=datetime.now(UTC))
@@ -405,7 +405,7 @@ class CommentViewSetListByUserTest(
they're not either enrolled or staff members.
"""
self.register_mock_endpoints()
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert json.loads(response.content)["developer_message"] == "Course not found."
@@ -416,7 +416,7 @@ class CommentViewSetListByUserTest(
comments in that course.
"""
self.register_mock_endpoints()
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
CourseEnrollmentFactory.create(user=self.other_user, course_id=self.course.id)
self.assert_successful_response(self.client.get(self.url))
@@ -425,7 +425,7 @@ class CommentViewSetListByUserTest(
Staff users are allowed to get any user's comments.
"""
self.register_mock_endpoints()
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
GlobalStaff().add_users(self.other_user)
self.assert_successful_response(self.client.get(self.url))
@@ -436,7 +436,7 @@ class CommentViewSetListByUserTest(
course.
"""
self.register_mock_endpoints()
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
role(course_key=self.course.id).add_users(self.other_user)
self.assert_successful_response(self.client.get(self.url))
@@ -445,7 +445,7 @@ class CommentViewSetListByUserTest(
Requests for users that don't exist result in a 404 response.
"""
self.register_mock_endpoints()
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
GlobalStaff().add_users(self.other_user)
url = self.build_url("non_existent", self.course.id)
response = self.client.get(url)
@@ -456,7 +456,7 @@ class CommentViewSetListByUserTest(
Requests for courses that don't exist result in a 404 response.
"""
self.register_mock_endpoints()
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
GlobalStaff().add_users(self.other_user)
url = self.build_url(self.user.username, "course-v1:x+y+z")
response = self.client.get(url)
@@ -467,7 +467,7 @@ class CommentViewSetListByUserTest(
Requests with invalid course ID should fail form validation.
"""
self.register_mock_endpoints()
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
GlobalStaff().add_users(self.other_user)
url = self.build_url(self.user.username, "an invalid course")
response = self.client.get(url)
@@ -484,7 +484,7 @@ class CommentViewSetListByUserTest(
self.register_get_threads_response(threads=[], page=1, num_pages=1)
self.register_get_comments_response(comments=[], page=1, num_pages=1)
- self.client.login(username=self.other_user.username, password="password")
+ self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
GlobalStaff().add_users(self.other_user)
url = self.build_url(self.user.username, self.course.id, page=2)
response = self.client.get(url)
@@ -929,7 +929,7 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi
"""
def setUp(self) -> None:
super().setUp()
- self.password = "password"
+ self.password = self.TEST_PASSWORD
self.user = UserFactory.create(password=self.password)
self.client.login(username=self.user.username, password=self.password)
self.staff = AdminFactory.create()
@@ -2357,6 +2357,7 @@ class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
@httpretty.activate
@disable_signal(api, 'comment_created')
@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
+@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock())
class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase):
"""Tests for CommentViewSet create"""
def setUp(self):
@@ -2758,7 +2759,7 @@ class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStor
discussion_topics={"Test Topic": {"id": "test_topic"}}
)
self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)})
- self.password = 'edx'
+ self.password = self.TEST_PASSWORD
self.user = UserFactory(username='staff', password=self.password, is_staff=True)
def _get_oauth_headers(self, user):
@@ -3056,7 +3057,7 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe
run="z",
start=datetime.now(UTC),
)
- self.password = 'edx'
+ self.password = self.TEST_PASSWORD
self.user = UserFactory(username='staff', password=self.password, is_staff=True)
course_key = CourseKey.from_string('course-v1:x+y+z')
seed_permissions_roles(course_key)
@@ -3264,7 +3265,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
user = UserFactory.create(
username=stat['username'],
email=f"{stat['username']}@example.com",
- password='12345'
+ password=self.TEST_PASSWORD
)
CourseEnrollment.enroll(user, self.course.id, mode='audit')
@@ -3278,7 +3279,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
"""
Tests that for a regular user stats are returned without flag counts
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats_without_flags
@@ -3288,7 +3289,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
"""
Tests that for a moderator user stats are returned with flag counts
"""
- self.client.login(username=self.moderator.username, password='test')
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
data = response.json()
assert data["results"] == self.stats
@@ -3308,7 +3309,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
"""
Test valid sorting options and defaults
"""
- self.client.login(username=username, password='test')
+ self.client.login(username=username, password=self.TEST_PASSWORD)
params = {}
if ordering_requested:
params = {"order_by": ordering_requested}
@@ -3326,7 +3327,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
"""
Test for invalid sorting options for regular users.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, {"order_by": order_by})
assert "order_by" in response.json()["field_errors"]
@@ -3341,7 +3342,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
Test for endpoint with username param.
"""
params = {'username': username_search_string}
- self.client.login(username=self.moderator.username, password='test')
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
self.client.get(self.url, params)
assert urlparse(
httpretty.last_request().path # lint-amnesty, pylint: disable=no-member
@@ -3356,7 +3357,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM
Test for endpoint with username param with no matches.
"""
params = {'username': 'unknown'}
- self.client.login(username=self.moderator.username, password='test')
+ self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url, params)
data = response.json()
self.assertFalse(data['results'])
diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py
index 5db131bec8..39dddcf33f 100644
--- a/lms/djangoapps/discussion/rest_api/tests/utils.py
+++ b/lms/djangoapps/discussion/rest_api/tests/utils.py
@@ -425,6 +425,18 @@ class CommentsServiceMockMixin:
status=200
)
+ def register_get_subscriptions(self, thread_id, response):
+ """
+ Register a mock response for GET on the CS comment active threads endpoint
+ """
+ assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.'
+ httpretty.register_uri(
+ httpretty.GET,
+ f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions",
+ body=json.dumps(response),
+ status=200
+ )
+
def assert_query_params_equal(self, httpretty_request, expected_params):
"""
Assert that the given mock request had the expected query parameters
diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py
index 0b84cf2ac6..e7dca49910 100644
--- a/lms/djangoapps/discussion/rest_api/utils.py
+++ b/lms/djangoapps/discussion/rest_api/utils.py
@@ -2,33 +2,23 @@
Utils for discussion API.
"""
from datetime import datetime
-from pytz import UTC
-from typing import List, Dict
+from typing import Dict, List
-from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.paginator import Paginator
from django.db.models.functions import Length
-from django.utils.translation import gettext_lazy as _
+from pytz import UTC
-from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole
-from lms.djangoapps.discussion.django_comment_client.permissions import get_team
+from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges
-from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings, CourseUserGroup
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction
-from openedx.core.djangoapps.discussions.utils import get_divided_discussions
from openedx.core.djangoapps.django_comment_common.models import (
- Role,
- CourseDiscussionSettings,
FORUM_ROLE_ADMINISTRATOR,
- FORUM_ROLE_MODERATOR,
- FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_COMMUNITY_TA,
+ FORUM_ROLE_GROUP_MODERATOR,
+ FORUM_ROLE_MODERATOR,
+ Role
)
-from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED
-from openedx_events.learning.data import UserNotificationData
-from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
class AttributeDict(dict):
@@ -371,196 +361,6 @@ def get_archived_topics(filtered_topic_ids: List[str], topics: List[Dict[str, st
return archived_topics
-def is_discussion_cohorted(course_key_str):
- """
- Returns if the discussion is divided by cohorts
- """
- cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str)
- discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
- return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions
-
-
-class DiscussionNotificationSender:
- """
- Class to send notifications to users who are subscribed to the thread.
- """
-
- def __init__(self, thread, course, creator, parent_id=None):
- self.thread = thread
- self.course = course
- self.creator = creator
- self.parent_id = parent_id
- self.parent_response = None
- self._get_parent_response()
-
- def _send_notification(self, user_ids, notification_type, extra_context=None):
- """
- Send notification to users
- """
- if not user_ids:
- return
-
- if extra_context is None:
- extra_context = {}
-
- notification_data = UserNotificationData(
- user_ids=[int(user_id) for user_id in user_ids],
- context={
- "replier_name": self.creator.username,
- "post_title": self.thread.title,
- "course_name": self.course.display_name,
- **extra_context,
- },
- notification_type=notification_type,
- content_url=f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/{self.thread.id}",
- app_name="discussion",
- course_key=self.course.id,
- )
- USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data)
-
- def _get_parent_response(self):
- """
- Get parent response object
- """
- if self.parent_id and not self.parent_response:
- self.parent_response = Comment(id=self.parent_id).retrieve()
-
- return self.parent_response
-
- def _create_cohort_course_audience(self):
- """
- Creates audience based on user cohort and role
- """
- course_key_str = str(self.course.id)
- discussion_cohorted = is_discussion_cohorted(course_key_str)
-
- # Retrieves cohort divided discussion
- discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str)
- divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
- self.course,
- discussion_settings
- )
-
- # Checks if post has any cohort assigned
- group_id = self.thread.attributes['group_id']
- if group_id is not None:
- group_id = int(group_id)
-
- # Course wide topics
- topic_id = self.thread.attributes['commentable_id']
- all_topics = divided_inline_discussions + divided_course_wide_discussions
- topic_divided = topic_id in all_topics or discussion_settings.always_divide_inline_discussions
-
- # Team object from topic id
- team = get_team(topic_id)
-
- user_ids = []
- if team:
- user_ids = team.users.all().values_list('id', flat=True)
- elif discussion_cohorted and topic_divided and group_id is not None:
- users_in_cohort = CourseUserGroup.objects.filter(
- course_id=course_key_str, id=group_id
- ).values_list('users__id', flat=True)
- user_ids.extend(users_in_cohort)
-
- privileged_roles = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA]
- privileged_users = Role.objects.filter(
- name__in=privileged_roles,
- course_id=course_key_str
- ).values_list('users__id', flat=True)
- user_ids.extend(privileged_users)
-
- staff_users = CourseStaffRole(self.course.id).users_with_role().values_list('id', flat=True)
- user_ids.extend(staff_users)
-
- admin_users = CourseInstructorRole(self.course.id).users_with_role().values_list('id', flat=True)
- user_ids.extend(admin_users)
- else:
- user_ids = CourseEnrollment.objects.filter(
- course__id=course_key_str, is_active=True
- ).values_list('user__id', flat=True)
-
- unique_user_ids = list(set(user_ids))
- if self.creator.id in unique_user_ids:
- unique_user_ids.remove(self.creator.id)
- return unique_user_ids
-
- def send_new_response_notification(self):
- """
- Send notification to users who are subscribed to the main thread/post i.e.
- there is a response to the main thread.
- """
- if not self.parent_id and self.creator.id != int(self.thread.user_id):
- self._send_notification([self.thread.user_id], "new_response")
-
- def _response_and_thread_has_same_creator(self) -> bool:
- """
- Check if response and main thread have same author.
- """
- return int(self.parent_response.user_id) == int(self.thread.user_id)
-
- def _response_and_comment_has_same_creator(self):
- return int(self.parent_response.attributes['user_id']) == self.creator.id
-
- def send_new_comment_notification(self):
- """
- Send notification to parent thread creator i.e. comment on the response.
- """
- if (
- self.parent_response and
- self.creator.id != int(self.thread.user_id)
- ):
- # use your if author of response is same as author of post.
- # use 'their' if comment author is also response author.
- author_name = (
- # Translators: Replier commented on "your" response to your post
- _("your")
- if self._response_and_thread_has_same_creator()
- else (
- # Translators: Replier commented on "their" response to your post
- _("their")
- if self._response_and_comment_has_same_creator()
- else f"{self.parent_response.username}'s"
- )
- )
- context = {
- "author_name": str(author_name),
- }
- self._send_notification([self.thread.user_id], "new_comment", extra_context=context)
-
- def send_new_comment_on_response_notification(self):
- """
- Send notification to parent response creator i.e. comment on the response.
- Do not send notification if author of response is same as author of post.
- """
- if (
- self.parent_response and
- self.creator.id != int(self.parent_response.user_id) and not
- self._response_and_thread_has_same_creator()
- ):
- self._send_notification([self.parent_response.user_id], "new_comment_on_response")
-
- def send_new_thread_created_notification(self):
- """
- Send notification based on notification_type
- """
- thread_type = self.thread.attributes['thread_type']
- notification_type = (
- "new_question_post"
- if thread_type == "question"
- else ("new_discussion_post" if thread_type == "discussion" else "")
- )
- if notification_type not in ['new_discussion_post', 'new_question_post']:
- raise ValueError(f'Invalid notification type {notification_type}')
-
- user_ids = self._create_cohort_course_audience()
- context = {
- 'username': self.creator.username,
- 'post_title': self.thread.title
- }
- self._send_notification(user_ids, notification_type, context)
-
-
def is_posting_allowed(posting_restrictions: str, blackout_schedules: List):
"""
Check if posting is allowed based on the given posting restrictions and blackout schedules.
diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py
index ada3db7792..e0d3b869da 100644
--- a/lms/djangoapps/discussion/tests/test_views.py
+++ b/lms/djangoapps/discussion/tests/test_views.py
@@ -463,7 +463,7 @@ class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amne
CourseTeamFactory.create(discussion_topic_id=discussion_topic_id)
user_not_in_team = UserFactory.create()
CourseEnrollmentFactory.create(user=user_not_in_team, course_id=self.course.id)
- self.client.login(username=user_not_in_team.username, password='test')
+ self.client.login(username=user_not_in_team.username, password=self.TEST_PASSWORD)
mock_request.side_effect = make_mock_request_impl(
course=self.course,
@@ -624,7 +624,7 @@ class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: d
def test_html(self, mock_request):
_mock_text, mock_thread_id = self._create_mock_cohorted_thread(mock_request)
- self.client.login(username=self.student.username, password='test')
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
response = self.client.get(
reverse('single_thread', kwargs={
'course_id': str(self.course.id),
@@ -758,7 +758,7 @@ class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # l
if is_ajax:
headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse('single_thread', args=[str(self.course.id), commentable_id, "dummy_thread_id"]),
@@ -824,7 +824,7 @@ class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTes
text="dummy content",
thread_list=self.thread_list
)
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse("forum_form_discussion", args=[str(self.course.id)]),
HTTP_X_REQUESTED_WITH="XMLHttpRequest"
@@ -889,7 +889,7 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content
verify that the user does not have access to that thread.
"""
def call_single_thread():
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse('single_thread', args=[str(self.course.id), discussion_id, thread_id])
)
@@ -1113,7 +1113,7 @@ class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdT
if is_ajax:
headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse("forum_form_discussion", args=[str(self.course.id)]),
data=request_data,
@@ -1165,7 +1165,7 @@ class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupI
if is_ajax:
headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest"
- self.client.login(username=requesting_user.username, password='test')
+ self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD)
return self.client.get(
reverse('user_profile', args=[str(self.course.id), profiled_user.id]),
data=request_data,
@@ -1414,7 +1414,7 @@ class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase)
mock_request.side_effect = make_mock_request_impl(
course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID
)
- self.client.login(username=self.student.username, password='test')
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
response = self.client.get(
reverse('user_profile', kwargs={
@@ -2300,7 +2300,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase):
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("forum_form_discussion", args=[self.course.id])
response = self.client.get(url)
assert response.status_code == 302
@@ -2315,7 +2315,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase):
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("user_profile", args=[self.course.id, self.user.id])
response = self.client.get(url)
assert response.status_code == 302
@@ -2330,7 +2330,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase):
"""
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True):
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"])
response = self.client.get(url)
assert response.status_code == 302
diff --git a/lms/djangoapps/grades/rest_api/v1/tests/mixins.py b/lms/djangoapps/grades/rest_api/v1/tests/mixins.py
index e5e7f269e3..1d3a6a0b45 100644
--- a/lms/djangoapps/grades/rest_api/v1/tests/mixins.py
+++ b/lms/djangoapps/grades/rest_api/v1/tests/mixins.py
@@ -97,7 +97,7 @@ class GradeViewTestMixin(SharedModuleStoreTestCase):
def setUp(self):
super().setUp()
- self.password = 'test'
+ self.password = self.TEST_PASSWORD
self.global_staff = GlobalStaffFactory.create()
self.student = UserFactory(password=self.password, username='student', email='student@example.com')
self.other_student = UserFactory(
diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py
index 1fbfb6070d..cd2107ec7c 100644
--- a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py
+++ b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py
@@ -548,7 +548,7 @@ class CourseSubmissionHistoryWithDataTest(TestSubmittingProblems):
def setUp(self):
super().setUp()
self.namespaced_url = 'grades_api:v1:submission_history'
- self.password = 'test'
+ self.password = self.TEST_PASSWORD
self.basic_setup()
self.global_staff = GlobalStaffFactory.create()
diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py
index af33279515..58c5fb90aa 100644
--- a/lms/djangoapps/grades/signals/handlers.py
+++ b/lms/djangoapps/grades/signals/handlers.py
@@ -8,6 +8,7 @@ from logging import getLogger
from django.dispatch import receiver
from opaque_keys.edx.keys import LearningContextKey
+from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED
from submissions.models import score_reset, score_set
from xblock.scorable import ScorableXBlockMixin, Score
@@ -25,7 +26,7 @@ from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERS
from openedx.core.lib.grade_utils import is_score_higher_or_equal
from .. import events
-from ..constants import ScoreDatabaseTableEnum
+from ..constants import GradeOverrideFeatureEnum, ScoreDatabaseTableEnum
from ..course_grade_factory import CourseGradeFactory
from ..scores import weighted_score
from .signals import (
@@ -122,6 +123,10 @@ def submissions_score_reset_handler(sender, **kwargs): # pylint: disable=unused
def disconnect_submissions_signal_receiver(signal):
"""
Context manager to be used for temporarily disconnecting edx-submission's set or reset signal.
+
+ Clear Student State on ORA problems currently results in a set->reset signal pair getting fired
+ from submissions which leads to tasks being enqueued, one of which can never succeed. This context manager
+ fixes the issue by disconnecting the "set" handler during the clear_state operation.
"""
if signal == score_set:
handler = submissions_score_set_handler
@@ -300,3 +305,45 @@ def listen_for_course_grade_passed_first_time(sender, user_id, course_id, **kwar
"""
events.course_grade_passed_first_time(user_id, course_id)
events.fire_segment_event_on_course_grade_passed_first_time(user_id, course_id)
+
+
+@receiver(EXAM_ATTEMPT_VERIFIED)
+def exam_attempt_verified_event_handler(sender, signal, **kwargs): # pylint: disable=unused-argument
+ """
+ Consume `EXAM_ATTEMPT_VERIFIED` events from the event bus. This will trigger
+ an undo section override, if one exists.
+ """
+ from ..api import should_override_grade_on_rejected_exam, undo_override_subsection_grade
+
+ event_data = kwargs.get('exam_attempt')
+ user_data = event_data.student_user
+ course_key = event_data.course_key
+ usage_key = event_data.usage_key
+
+ if should_override_grade_on_rejected_exam(course_key):
+ undo_override_subsection_grade(user_data.id, course_key, usage_key, GradeOverrideFeatureEnum.proctoring)
+
+
+@receiver(EXAM_ATTEMPT_REJECTED)
+def exam_attempt_rejected_event_handler(sender, signal, **kwargs): # pylint: disable=unused-argument
+ """
+ Consume `EXAM_ATTEMPT_REJECTED` events from the event bus. This will trigger a subsection override.
+ """
+ from ..api import override_subsection_grade
+
+ event_data = kwargs.get('exam_attempt')
+ override_grade_value = 0.0
+ user_data = event_data.student_user
+ course_key = event_data.course_key
+ usage_key = event_data.usage_key
+
+ override_subsection_grade(
+ user_data.id,
+ course_key,
+ usage_key,
+ earned_all=override_grade_value,
+ earned_graded=override_grade_value,
+ feature=GradeOverrideFeatureEnum.proctoring,
+ overrider=None,
+ comment=None,
+ )
diff --git a/lms/djangoapps/grades/tests/integration/test_access.py b/lms/djangoapps/grades/tests/integration/test_access.py
index 380962b19e..6545094a3c 100644
--- a/lms/djangoapps/grades/tests/integration/test_access.py
+++ b/lms/djangoapps/grades/tests/integration/test_access.py
@@ -73,9 +73,9 @@ class GradesAccessIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreT
self.addCleanup(set_current_request, None)
self.request = get_mock_request(UserFactory())
self.student = self.request.user
- self.client.login(username=self.student.username, password="test")
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
CourseEnrollment.enroll(self.student, self.course.id)
- self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password='test')
+ self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password=self.TEST_PASSWORD)
self.refresh_course()
def test_subsection_access_changed(self):
diff --git a/lms/djangoapps/grades/tests/integration/test_events.py b/lms/djangoapps/grades/tests/integration/test_events.py
index 868b55eeae..881ad049b3 100644
--- a/lms/djangoapps/grades/tests/integration/test_events.py
+++ b/lms/djangoapps/grades/tests/integration/test_events.py
@@ -70,9 +70,9 @@ class GradesEventIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreTe
self.addCleanup(set_current_request, None)
self.request = get_mock_request(UserFactory())
self.student = self.request.user
- self.client.login(username=self.student.username, password="test")
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
CourseEnrollment.enroll(self.student, self.course.id)
- self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password='test')
+ self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password=self.TEST_PASSWORD)
self.refresh_course()
@patch('lms.djangoapps.grades.events.tracker')
diff --git a/lms/djangoapps/grades/tests/integration/test_problems.py b/lms/djangoapps/grades/tests/integration/test_problems.py
index 36c54bec86..b4a2d06c9f 100644
--- a/lms/djangoapps/grades/tests/integration/test_problems.py
+++ b/lms/djangoapps/grades/tests/integration/test_problems.py
@@ -43,9 +43,8 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase):
def setUp(self):
super().setUp()
- password = 'test'
- self.student = UserFactory.create(is_staff=False, username='test_student', password=password)
- self.client.login(username=self.student.username, password=password)
+ self.student = UserFactory.create(is_staff=False, username='test_student', password=self.TEST_PASSWORD)
+ self.client.login(username=self.student.username, password=self.TEST_PASSWORD)
self.addCleanup(set_current_request, None)
self.request = get_mock_request(self.student)
self.course_structure = get_course_blocks(self.student, self.course.location)
@@ -58,8 +57,7 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase):
For details on the contents and structure of the file, see
`common/test/data/scoreable/README`.
"""
- password = 'test'
- user = UserFactory.create(is_staff=False, username='test_student', password=password)
+ user = UserFactory.create(is_staff=False, username='test_student', password=cls.TEST_PASSWORD)
course_items = import_course_from_xml(
cls.store,
@@ -144,7 +142,7 @@ class TestVariedMetadata(ProblemSubmissionTestMixin, ModuleStoreTestCase):
'''
self.addCleanup(set_current_request, None)
self.request = get_mock_request(UserFactory())
- self.client.login(username=self.request.user.username, password="test")
+ self.client.login(username=self.request.user.username, password=self.TEST_PASSWORD)
CourseEnrollment.enroll(self.request.user, self.course.id)
def _get_altered_metadata(self, alterations):
diff --git a/lms/djangoapps/grades/tests/test_handlers.py b/lms/djangoapps/grades/tests/test_handlers.py
new file mode 100644
index 0000000000..72424987b5
--- /dev/null
+++ b/lms/djangoapps/grades/tests/test_handlers.py
@@ -0,0 +1,133 @@
+"""
+Tests for the grades handlers
+"""
+from datetime import datetime, timezone
+from unittest import mock
+from uuid import uuid4
+
+import ddt
+from django.test import TestCase
+from opaque_keys.edx.keys import CourseKey, UsageKey
+from openedx_events.data import EventsMetadata
+from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData
+from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED
+
+from common.djangoapps.student.tests.factories import UserFactory
+from lms.djangoapps.grades.signals.handlers import (
+ exam_attempt_rejected_event_handler,
+ exam_attempt_verified_event_handler
+)
+from ..constants import GradeOverrideFeatureEnum
+
+
+@ddt.ddt
+class ExamCompletionEventBusTests(TestCase):
+ """
+ Tests for exam events from the event bus
+ """
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course')
+ cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection'
+ cls.usage_key = UsageKey.from_string(cls.subsection_id)
+ cls.student_user = UserFactory(
+ username='student_user',
+ )
+
+ @staticmethod
+ def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None):
+ """ create ExamAttemptData object for exam based event """
+ if requesting_user:
+ requesting_user_data = UserData(
+ id=requesting_user.id,
+ is_active=True,
+ pii=None
+ )
+ else:
+ requesting_user_data = None
+
+ return ExamAttemptData(
+ student_user=UserData(
+ id=student_user.id,
+ is_active=True,
+ pii=UserPersonalData(
+ username=student_user.username,
+ email=student_user.email,
+ ),
+ ),
+ course_key=course_key,
+ usage_key=usage_key,
+ requesting_user=requesting_user_data,
+ exam_type=exam_type,
+ )
+
+ @staticmethod
+ def _get_exam_event_metadata(event_signal):
+ """ create metadata object for event """
+ return EventsMetadata(
+ event_type=event_signal.event_type,
+ id=uuid4(),
+ minorversion=0,
+ source='openedx/lms/web',
+ sourcehost='lms.test',
+ time=datetime.now(timezone.utc)
+ )
+
+ @ddt.data(
+ True,
+ False
+ )
+ @mock.patch('lms.djangoapps.grades.api.should_override_grade_on_rejected_exam')
+ @mock.patch('lms.djangoapps.grades.api.undo_override_subsection_grade')
+ def test_exam_attempt_verified_event_handler(self, override_enabled, mock_undo_override, mock_should_override):
+ mock_should_override.return_value = override_enabled
+
+ exam_event_data = self._get_exam_event_data(self.student_user,
+ self.course_key,
+ self.usage_key,
+ exam_type='proctored')
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_VERIFIED)
+
+ event_kwargs = {
+ 'exam_attempt': exam_event_data,
+ 'metadata': event_metadata
+ }
+ exam_attempt_verified_event_handler(None, EXAM_ATTEMPT_VERIFIED, ** event_kwargs)
+
+ if override_enabled:
+ mock_undo_override.assert_called_once_with(
+ self.student_user.id,
+ self.course_key,
+ self.usage_key,
+ GradeOverrideFeatureEnum.proctoring
+ )
+ else:
+ mock_undo_override.assert_not_called()
+
+ @mock.patch('lms.djangoapps.grades.api.override_subsection_grade')
+ def test_exam_attempt_rejected_event_handler(self, mock_override):
+ exam_event_data = self._get_exam_event_data(self.student_user,
+ self.course_key,
+ self.usage_key,
+ exam_type='proctored')
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_REJECTED)
+
+ event_kwargs = {
+ 'exam_attempt': exam_event_data,
+ 'metadata': event_metadata
+ }
+ exam_attempt_rejected_event_handler(None, EXAM_ATTEMPT_REJECTED, ** event_kwargs)
+
+ override_grade_value = 0.0
+
+ mock_override.assert_called_once_with(
+ self.student_user.id,
+ self.course_key,
+ self.usage_key,
+ earned_all=override_grade_value,
+ earned_graded=override_grade_value,
+ feature=GradeOverrideFeatureEnum.proctoring,
+ overrider=None,
+ comment=None,
+ )
diff --git a/lms/djangoapps/instructor/apps.py b/lms/djangoapps/instructor/apps.py
index 898416585f..d22c8b0aa1 100644
--- a/lms/djangoapps/instructor/apps.py
+++ b/lms/djangoapps/instructor/apps.py
@@ -36,6 +36,7 @@ class InstructorConfig(AppConfig):
}
def ready(self):
+ from . import handlers # pylint: disable=unused-import,import-outside-toplevel
if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'):
from .services import InstructorService
set_runtime_service('instructor', InstructorService())
diff --git a/lms/djangoapps/instructor/handlers.py b/lms/djangoapps/instructor/handlers.py
new file mode 100644
index 0000000000..af740ee4fe
--- /dev/null
+++ b/lms/djangoapps/instructor/handlers.py
@@ -0,0 +1,82 @@
+"""
+Handlers for instructor
+"""
+import logging
+
+from django.contrib.auth import get_user_model
+from django.core.exceptions import ObjectDoesNotExist
+from django.dispatch import receiver
+from openedx_events.learning.signals import EXAM_ATTEMPT_RESET, EXAM_ATTEMPT_SUBMITTED
+
+from lms.djangoapps.courseware.models import StudentModule
+from lms.djangoapps.instructor import enrollment
+from lms.djangoapps.instructor.tasks import update_exam_completion_task
+
+User = get_user_model()
+
+log = logging.getLogger(__name__)
+
+
+@receiver(EXAM_ATTEMPT_SUBMITTED)
+def handle_exam_completion(sender, signal, **kwargs):
+ """
+ exam completion event from the event bus
+ """
+ event_data = kwargs.get('exam_attempt')
+ user_data = event_data.student_user
+ usage_key = event_data.usage_key
+
+ update_exam_completion_task.apply_async((user_data.pii.username, str(usage_key), 1.0))
+
+
+@receiver(EXAM_ATTEMPT_RESET)
+def handle_exam_reset(sender, signal, **kwargs):
+ """
+ exam reset event from the event bus
+ """
+ event_data = kwargs.get('exam_attempt')
+ user_data = event_data.student_user
+ requesting_user_data = event_data.requesting_user
+ usage_key = event_data.usage_key
+ course_key = event_data.course_key
+ content_id = str(usage_key)
+
+ try:
+ student = User.objects.get(id=user_data.id)
+ except ObjectDoesNotExist:
+ log.error(
+ 'Error occurred while attempting to reset student attempt for user_id '
+ f'{user_data.id} for content_id {content_id}. '
+ 'User does not exist!'
+ )
+ return
+
+ try:
+ requesting_user = User.objects.get(id=requesting_user_data.id)
+ except ObjectDoesNotExist:
+ log.error(
+ 'Error occurred while attempting to reset student attempt. Requesting user_id '
+ f'{requesting_user_data.id} does not exist!'
+ )
+ return
+
+ # reset problem state
+ try:
+ enrollment.reset_student_attempts(
+ course_key,
+ student,
+ usage_key,
+ requesting_user=requesting_user,
+ delete_module=True,
+ )
+ except (StudentModule.DoesNotExist, enrollment.sub_api.SubmissionError):
+ log.error(
+ 'Error occurred while attempting to reset module state for user_id '
+ f'{student.id} for content_id {content_id}.'
+ )
+
+ # In some cases, reset_student_attempts does not clear the entire exam's completion state.
+ # One example of this is an exam with multiple units (verticals) within it and the learner
+ # never viewing one of the units. All of the content in that unit will still be marked complete,
+ # but the reset code is unable to handle clearing the completion in that scenario.
+ update_exam_completion_task.apply_async((student.username, content_id, 0.0))
diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py
index 20426761e6..119ecf4e35 100644
--- a/lms/djangoapps/instructor/permissions.py
+++ b/lms/djangoapps/instructor/permissions.py
@@ -29,6 +29,8 @@ EMAIL = 'instructor.email'
RESCORE_EXAMS = 'instructor.rescore_exams'
VIEW_REGISTRATION = 'instructor.view_registration'
VIEW_DASHBOARD = 'instructor.dashboard'
+VIEW_ENROLLMENTS = 'instructor.view_enrollments'
+VIEW_FORUM_MEMBERS = 'instructor.view_forum_members'
perms[ALLOW_STUDENT_TO_BYPASS_ENTRANCE_EXAM] = HasAccessRule('staff')
@@ -60,3 +62,5 @@ perms[VIEW_DASHBOARD] = \
'instructor',
'data_researcher'
) | HasAccessRule('staff') | HasAccessRule('instructor')
+perms[VIEW_ENROLLMENTS] = HasAccessRule('staff')
+perms[VIEW_FORUM_MEMBERS] = HasAccessRule('staff')
diff --git a/lms/djangoapps/instructor/tasks.py b/lms/djangoapps/instructor/tasks.py
index ac3bda13a8..2ec19c35b8 100644
--- a/lms/djangoapps/instructor/tasks.py
+++ b/lms/djangoapps/instructor/tasks.py
@@ -5,14 +5,14 @@ import logging
from celery import shared_task
from celery_utils.logged_task import LoggedTask
from django.core.exceptions import ObjectDoesNotExist
+from edx_django_utils.monitoring import set_code_owner_attribute
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey
from xblock.completable import XBlockCompletionMode
-from edx_django_utils.monitoring import set_code_owner_attribute
from common.djangoapps.student.models import get_user_by_username_or_email
-from lms.djangoapps.courseware.model_data import FieldDataCache
from lms.djangoapps.courseware.block_render import get_block_for_descriptor
+from lms.djangoapps.courseware.model_data import FieldDataCache
from openedx.core.lib.request_utils import get_request_or_stub
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py
index 468bca31f7..bb534d9b02 100644
--- a/lms/djangoapps/instructor/tests/test_api.py
+++ b/lms/djangoapps/instructor/tests/test_api.py
@@ -325,7 +325,7 @@ class TestEndpointHttpMethods(SharedModuleStoreTestCase, LoginEnrollmentTestCase
"""
super().setUp()
global_user = GlobalStaffFactory()
- self.client.login(username=global_user.username, password='test')
+ self.client.login(username=global_user.username, password=self.TEST_PASSWORD)
@ddt.data(*INSTRUCTOR_POST_ENDPOINTS)
def test_endpoints_reject_get(self, data):
@@ -478,7 +478,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
"""
Ensure that an enrolled student can't access staff or instructor endpoints.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
for endpoint, args in self.staff_level_endpoints:
self._access_endpoint(
@@ -518,7 +518,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
CourseEnrollment.enroll(staff_member, self.course.id)
CourseFinanceAdminRole(self.course.id).add_users(staff_member)
CourseDataResearcherRole(self.course.id).add_users(staff_member)
- self.client.login(username=staff_member.username, password='test')
+ self.client.login(username=staff_member.username, password=self.TEST_PASSWORD)
# Try to promote to forums admin - not working
# update_forum_role(self.course.id, staff_member, FORUM_ROLE_ADMINISTRATOR, 'allow')
@@ -558,7 +558,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest
CourseFinanceAdminRole(self.course.id).add_users(inst)
CourseDataResearcherRole(self.course.id).add_users(inst)
- self.client.login(username=inst.username, password='test')
+ self.client.login(username=inst.username, password=self.TEST_PASSWORD)
for endpoint, args in self.staff_level_endpoints:
expected_status = 200
@@ -632,7 +632,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas
self.audit_course_instructor = InstructorFactory(course_key=self.audit_course.id)
self.white_label_course_instructor = InstructorFactory(course_key=self.white_label_course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.not_enrolled_student = UserFactory(
username='NotEnrolledStudent',
@@ -945,7 +945,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas
Test that enrollment mode for audit courses (paid courses) is 'audit'.
"""
# Login Audit Course instructor
- self.client.login(username=self.audit_course_instructor.username, password='test')
+ self.client.login(username=self.audit_course_instructor.username, password=self.TEST_PASSWORD)
csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA"
uploaded_file = SimpleUploadedFile("temp.csv", csv_content)
@@ -976,7 +976,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas
self.white_label_course_mode.save()
# Login Audit Course instructor
- self.client.login(username=self.white_label_course_instructor.username, password='test')
+ self.client.login(username=self.white_label_course_instructor.username, password=self.TEST_PASSWORD)
csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA"
uploaded_file = SimpleUploadedFile("temp.csv", csv_content)
@@ -1024,7 +1024,7 @@ class TestInstructorAPIEnrollment(SharedModuleStoreTestCase, LoginEnrollmentTest
self.request = RequestFactory().request()
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.enrolled_student = UserFactory(username='EnrolledStudent', first_name='Enrolled', last_name='Student')
CourseEnrollment.enroll(
@@ -1873,7 +1873,7 @@ class TestInstructorAPIBulkBetaEnrollment(SharedModuleStoreTestCase, LoginEnroll
super().setUp()
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.beta_tester = BetaTesterFactory(course_key=self.course.id)
CourseEnrollment.enroll(
@@ -2218,7 +2218,7 @@ class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTe
super().setUp()
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.other_instructor = InstructorFactory(course_key=self.course.id)
self.other_staff = StaffFactory(course_key=self.course.id)
@@ -2458,7 +2458,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
self.course_mode.save()
self.instructor = InstructorFactory(course_key=self.course.id)
CourseDataResearcherRole(self.course.id).add_users(self.instructor)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.students = [UserFactory() for _ in range(6)]
for student in self.students:
@@ -2600,7 +2600,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment
}))
course_instructor = InstructorFactory(course_key=self.course.id)
CourseDataResearcherRole(self.course.id).add_users(course_instructor)
- self.client.login(username=course_instructor.username, password='test')
+ self.client.login(username=course_instructor.username, password=self.TEST_PASSWORD)
url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)})
@@ -2972,7 +2972,7 @@ class TestInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginEnrollmentTes
def setUp(self):
super().setUp()
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.student = UserFactory()
CourseEnrollment.enroll(self.student, self.course.id)
@@ -3158,7 +3158,7 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE
self.instructor = InstructorFactory(course_key=self.course.id)
# Add instructor to invalid ee course
CourseInstructorRole(self.course_with_invalid_ee.id).add_users(self.instructor)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.student = UserFactory()
CourseEnrollment.enroll(self.student, self.course.id)
@@ -3261,7 +3261,7 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE
""" Test entrance exam delete state failure with staff access. """
self.client.logout()
staff_user = StaffFactory(course_key=self.course.id)
- self.client.login(username=staff_user.username, password='test')
+ self.client.login(username=staff_user.username, password=self.TEST_PASSWORD)
url = reverse('reset_student_attempts_for_entrance_exam',
kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, {
@@ -3414,7 +3414,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm
}
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
def tearDown(self):
super().tearDown()
@@ -3434,7 +3434,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm
def test_send_email_but_not_staff(self):
self.client.logout()
student = UserFactory()
- self.client.login(username=student.username, password='test')
+ self.client.login(username=student.username, password=self.TEST_PASSWORD)
url = reverse('send_email', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, self.full_test_message)
assert response.status_code == 403
@@ -3631,7 +3631,7 @@ class TestInstructorAPITaskLists(SharedModuleStoreTestCase, LoginEnrollmentTestC
def setUp(self):
super().setUp()
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.student = UserFactory()
CourseEnrollment.enroll(self.student, self.course.id)
@@ -3768,7 +3768,7 @@ class TestInstructorEmailContentList(SharedModuleStoreTestCase, LoginEnrollmentT
super().setUp()
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.tasks = {}
self.emails = {}
self.emails_info = {}
@@ -4030,7 +4030,7 @@ class TestDueDateExtensions(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
CourseEnrollmentFactory.create(user=self.user1, course_id=self.course.id)
CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id)
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
extract_dates(None, self.course.id)
def test_change_due_date(self):
@@ -4206,7 +4206,7 @@ class TestDueDateExtensionsDeletedDate(ModuleStoreTestCase, LoginEnrollmentTestC
CourseEnrollmentFactory.create(user=self.user1, course_id=self.course.id)
CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id)
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
extract_dates(None, self.course.id)
@override_waffle_flag(RELATIVE_DATES_FLAG, active=True)
@@ -4251,7 +4251,7 @@ class TestCourseIssuedCertificatesData(SharedModuleStoreTestCase):
def setUp(self):
super().setUp()
self.instructor = InstructorFactory(course_key=self.course.id)
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
def generate_certificate(self, course_id, mode, status):
"""
@@ -4379,7 +4379,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase):
"""
Verify that we get the error we expect for a given file input.
"""
- self.client.login(username=self.staff_user.username, password='test')
+ self.client.login(username=self.staff_user.username, password=self.TEST_PASSWORD)
response = self.call_add_users_to_cohorts(file_content, suffix=file_suffix)
assert response.status_code == 400
result = json.loads(response.content.decode('utf-8'))
@@ -4392,7 +4392,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase):
background task.
"""
mock_store_upload.return_value = (None, 'fake_file_name.csv')
- self.client.login(username=self.staff_user.username, password='test')
+ self.client.login(username=self.staff_user.username, password=self.TEST_PASSWORD)
response = self.call_add_users_to_cohorts(file_content)
assert response.status_code == 204
assert mock_store_upload.called
@@ -4438,7 +4438,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase):
"""
Verify that we can't access the view when we aren't a staff user.
"""
- self.client.login(username=self.non_staff_user.username, password='test')
+ self.client.login(username=self.non_staff_user.username, password=self.TEST_PASSWORD)
response = self.call_add_users_to_cohorts('')
assert response.status_code == 403
diff --git a/lms/djangoapps/instructor/tests/test_api_email_localization.py b/lms/djangoapps/instructor/tests/test_api_email_localization.py
index e0cef6a7af..a23b94ece0 100644
--- a/lms/djangoapps/instructor/tests/test_api_email_localization.py
+++ b/lms/djangoapps/instructor/tests/test_api_email_localization.py
@@ -35,7 +35,7 @@ class TestInstructorAPIEnrollmentEmailLocalization(SharedModuleStoreTestCase):
# Esperanto.
self.instructor = InstructorFactory(course_key=self.course.id)
set_user_preference(self.instructor, LANGUAGE_KEY, 'zh-cn')
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.student = UserFactory.create()
set_user_preference(self.student, LANGUAGE_KEY, 'eo')
diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py
index 5350d1d07f..11fd82bfb3 100644
--- a/lms/djangoapps/instructor/tests/test_certificates.py
+++ b/lms/djangoapps/instructor/tests/test_certificates.py
@@ -64,11 +64,11 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
def test_visible_only_to_global_staff(self):
# Instructors don't see the certificates section
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self._assert_certificates_visible(False)
# Global staff can see the certificates section
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
self._assert_certificates_visible(True)
def test_visible_only_when_feature_flag_enabled(self):
@@ -77,17 +77,17 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
cache.clear()
# Now even global staff can't see the certificates section
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
self._assert_certificates_visible(False)
@ddt.data("started", "error", "success")
def test_show_certificate_status(self, status):
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
with self._certificate_status("honor", status):
self._assert_certificate_status("honor", status)
def test_show_enabled_button(self):
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
# Initially, no example certs are generated, so
# the enable button should be disabled
@@ -104,7 +104,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
self._assert_enable_certs_button(False)
def test_can_disable_even_after_failure(self):
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
with self._certificate_status("honor", "error"):
# When certs are disabled for a course, then don't allow them
@@ -127,7 +127,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
self.course.cert_html_view_enabled = True
self.course.save()
self.store.update_item(self.course, self.global_staff.id)
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
self.assertContains(response, 'Enable Student-Generated Certificates')
self.assertContains(response, 'enable-certificates-submit')
@@ -143,7 +143,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
self.course.cert_html_view_enabled = True
self.course.save()
self.store.update_item(self.course, self.global_staff.id)
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
self.assertContains(response, 'Enable Student-Generated Certificates')
self.assertContains(response, 'enable-certificates-submit')
@@ -233,18 +233,18 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
url = reverse(url_name, kwargs={'course_id': self.course.id})
# Instructors do not have access
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
response = self.client.post(url)
assert response.status_code == 403
# Global staff have access
- self.client.login(username=self.global_staff.username, password='test')
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
response = self.client.post(url)
assert response.status_code == 302
@ddt.data(True, False)
def test_enable_certificate_generation(self, is_enabled):
- self.client.login(username=self.global_staff.username, password='test')
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
url = reverse(
'enable_certificate_generation',
kwargs={'course_id': str(self.course.id)}
@@ -274,7 +274,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
user who made the request is not member of global staff.
"""
user = UserFactory.create()
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
url = reverse(
'start_certificate_generation',
kwargs={'course_id': str(self.course.id)}
@@ -283,7 +283,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
response = self.client.post(url)
assert response.status_code == 403
- self.client.login(username=self.instructor.username, password='test')
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
response = self.client.post(url)
assert response.status_code == 403
@@ -292,7 +292,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
Test certificates generation api endpoint returns success status when called with
valid course key
"""
- self.client.login(username=self.global_staff.username, password='test')
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
url = reverse(
'start_certificate_generation',
kwargs={'course_id': str(self.course.id)}
@@ -319,7 +319,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
)
# Login the client and access the url with 'certificate_statuses'
- self.client.login(username=self.global_staff.username, password='test')
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.downloadable]})
@@ -352,7 +352,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
)
# Login the client and access the url without 'certificate_statuses'
- self.client.login(username=self.global_staff.username, password='test')
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url)
@@ -419,7 +419,7 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase):
# Enable certificate generation
cache.clear()
CertificateGenerationConfiguration.objects.create(enabled=True)
- self.client.login(username=self.global_staff.username, password='test')
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
def test_certificate_exception_added_successfully(self):
"""
@@ -712,7 +712,7 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase):
# Enable certificate generation
cache.clear()
CertificateGenerationConfiguration.objects.create(enabled=True)
- self.client.login(username=self.global_staff.username, password='test')
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
def test_generate_certificate_exceptions_all_students(self):
"""
@@ -830,7 +830,7 @@ class TestCertificatesInstructorApiBulkAllowlist(SharedModuleStoreTestCase):
CourseEnrollment.enroll(self.enrolled_user_2, self.course.id)
# Global staff can see the certificates section
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
def test_create_allowlist_exception_record(self):
"""
@@ -1026,7 +1026,7 @@ class CertificateInvalidationViewTests(SharedModuleStoreTestCase):
)
# Global staff can see the certificates section
- self.client.login(username=self.global_staff.username, password="test")
+ self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD)
def test_invalidate_certificate(self):
"""
diff --git a/lms/djangoapps/instructor/tests/test_email.py b/lms/djangoapps/instructor/tests/test_email.py
index a84c728c30..322c734fa6 100644
--- a/lms/djangoapps/instructor/tests/test_email.py
+++ b/lms/djangoapps/instructor/tests/test_email.py
@@ -40,7 +40,7 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase):
# Create instructor account
instructor = AdminFactory.create()
- self.client.login(username=instructor.username, password="test")
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
def tearDown(self):
super().tearDown()
@@ -148,7 +148,7 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase):
# Create instructor account
instructor = AdminFactory.create()
- self.client.login(username=instructor.username, password="test")
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
# URL for instructor dash
self.url = reverse('instructor_dashboard', kwargs={'course_id': str(self.course_key)})
diff --git a/lms/djangoapps/instructor/tests/test_filters.py b/lms/djangoapps/instructor/tests/test_filters.py
index 19948da144..a676ab92a5 100644
--- a/lms/djangoapps/instructor/tests/test_filters.py
+++ b/lms/djangoapps/instructor/tests/test_filters.py
@@ -99,7 +99,7 @@ class InstructorDashboardFiltersTest(ModuleStoreTestCase):
"""
super().setUp()
self.instructor = AdminFactory.create()
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.course = CourseFactory.create(
org="test1", course="course1", display_name="run1",
)
diff --git a/lms/djangoapps/instructor/tests/test_handlers.py b/lms/djangoapps/instructor/tests/test_handlers.py
new file mode 100644
index 0000000000..ee29e99eea
--- /dev/null
+++ b/lms/djangoapps/instructor/tests/test_handlers.py
@@ -0,0 +1,229 @@
+"""
+Unit tests for instructor signals
+"""
+from datetime import datetime, timezone
+from unittest import mock
+from uuid import uuid4
+
+from django.test import TestCase
+from opaque_keys.edx.keys import CourseKey, UsageKey
+from openedx_events.data import EventsMetadata
+from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData
+from openedx_events.learning.signals import EXAM_ATTEMPT_RESET, EXAM_ATTEMPT_SUBMITTED
+
+from common.djangoapps.student.tests.factories import UserFactory
+from lms.djangoapps.instructor import enrollment
+from lms.djangoapps.instructor.handlers import handle_exam_completion, handle_exam_reset
+
+
+class ExamCompletionEventBusTests(TestCase):
+ """
+ Tests completion events from the event bus.
+ """
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course')
+ cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection'
+ cls.subsection_key = UsageKey.from_string(cls.subsection_id)
+ cls.student_user = UserFactory(
+ username='student_user',
+ )
+
+ @staticmethod
+ def _get_exam_event_data(student_user, course_key, usage_key, requesting_user=None):
+ """ create ExamAttemptData object for exam based event """
+ if requesting_user:
+ requesting_user_data = UserData(
+ id=requesting_user.id,
+ is_active=True,
+ pii=None
+ )
+ else:
+ requesting_user_data = None
+
+ return ExamAttemptData(
+ student_user=UserData(
+ id=student_user.id,
+ is_active=True,
+ pii=UserPersonalData(
+ username=student_user.username,
+ email=student_user.email,
+ ),
+ ),
+ course_key=course_key,
+ usage_key=usage_key,
+ exam_type='timed',
+ requesting_user=requesting_user_data,
+ )
+
+ @staticmethod
+ def _get_exam_event_metadata(event_signal):
+ """ create metadata object for event """
+ return EventsMetadata(
+ event_type=event_signal.event_type,
+ id=uuid4(),
+ minorversion=0,
+ source='openedx/lms/web',
+ sourcehost='lms.test',
+ time=datetime.now(timezone.utc)
+ )
+
+ @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True)
+ def test_submit_exam_completion_event(self, mock_task_apply):
+ """
+ Assert update completion task is scheduled
+ """
+ exam_event_data = self._get_exam_event_data(self.student_user, self.course_key, self.subsection_key)
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_SUBMITTED)
+
+ event_kwargs = {
+ 'exam_attempt': exam_event_data,
+ 'metadata': event_metadata
+ }
+ handle_exam_completion(None, EXAM_ATTEMPT_SUBMITTED, **event_kwargs)
+ mock_task_apply.assert_called_once_with(('student_user', self.subsection_id, 1.0))
+
+ @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True)
+ @mock.patch('lms.djangoapps.instructor.enrollment.reset_student_attempts', autospec=True)
+ def test_exam_reset_event(self, mock_reset, mock_task_apply):
+ """
+ Assert problem state and completion are reset
+ """
+ staff_user = UserFactory(
+ username='staff_user',
+ is_staff=True,
+ )
+
+ exam_event_data = self._get_exam_event_data(
+ self.student_user,
+ self.course_key,
+ self.subsection_key,
+ requesting_user=staff_user
+ )
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET)
+
+ event_kwargs = {
+ 'exam_attempt': exam_event_data,
+ 'metadata': event_metadata
+ }
+
+ # reset signal
+ handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs)
+
+ # make sure problem attempts have been deleted
+ mock_reset.assert_called_once_with(
+ self.course_key,
+ self.student_user,
+ self.subsection_key,
+ requesting_user=staff_user,
+ delete_module=True,
+ )
+
+ # Assert we update completion with 0.0
+ mock_task_apply.assert_called_once_with(('student_user', self.subsection_id, 0.0))
+
+ def test_exam_reset_bad_user(self):
+ staff_user = UserFactory(
+ username='staff_user',
+ is_staff=True,
+ )
+
+ # not a user
+ bad_user_data = ExamAttemptData(
+ student_user=UserData(
+ id=999,
+ is_active=True,
+ pii=UserPersonalData(
+ username='user_dne',
+ email='user_dne@example.com',
+ ),
+ ),
+ course_key=self.course_key,
+ usage_key=self.subsection_key,
+ exam_type='timed',
+ requesting_user=UserData(
+ id=staff_user.id,
+ is_active=True,
+ pii=None
+ ),
+ )
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET)
+ event_kwargs = {
+ 'exam_attempt': bad_user_data,
+ 'metadata': event_metadata
+ }
+
+ # reset signal
+ with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log:
+ handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs)
+ mock_log.assert_called_once_with(
+ 'Error occurred while attempting to reset student attempt for user_id '
+ f'999 for content_id {bad_user_data.usage_key}. '
+ 'User does not exist!'
+ )
+
+ def test_exam_reset_bad_requesting_user(self):
+ # requesting user is not a user
+ bad_user_data = ExamAttemptData(
+ student_user=UserData(
+ id=self.student_user.id,
+ is_active=True,
+ pii=UserPersonalData(
+ username='user_dne',
+ email='user_dne@example.com',
+ ),
+ ),
+ course_key=self.course_key,
+ usage_key=self.subsection_key,
+ exam_type='timed',
+ requesting_user=UserData(
+ id=999,
+ is_active=True,
+ pii=None
+ ),
+ )
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET)
+ event_kwargs = {
+ 'exam_attempt': bad_user_data,
+ 'metadata': event_metadata
+ }
+
+ # reset signal
+ with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log:
+ handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs)
+ mock_log.assert_called_once_with(
+ 'Error occurred while attempting to reset student attempt. Requesting user_id '
+ '999 does not exist!'
+ )
+
+ @mock.patch(
+ 'lms.djangoapps.instructor.enrollment.reset_student_attempts',
+ side_effect=enrollment.sub_api.SubmissionError
+ )
+ def test_module_reset_failure(self, mock_reset):
+ staff_user = UserFactory(
+ username='staff_user',
+ is_staff=True,
+ )
+
+ exam_event_data = self._get_exam_event_data(
+ self.student_user,
+ self.course_key,
+ self.subsection_key,
+ requesting_user=staff_user
+ )
+ event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET)
+
+ event_kwargs = {
+ 'exam_attempt': exam_event_data,
+ 'metadata': event_metadata
+ }
+
+ with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log:
+ # reset signal
+ handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs)
+ mock_log.assert_called_once_with(
+ 'Error occurred while attempting to reset module state for user_id '
+ f'{self.student_user.id} for content_id {self.subsection_id}.'
+ )
diff --git a/lms/djangoapps/instructor/tests/test_proctoring.py b/lms/djangoapps/instructor/tests/test_proctoring.py
index 491a2ee1f7..4b196f307b 100644
--- a/lms/djangoapps/instructor/tests/test_proctoring.py
+++ b/lms/djangoapps/instructor/tests/test_proctoring.py
@@ -36,7 +36,7 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase):
# Create instructor account
self.instructor = AdminFactory.create()
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
def setup_course_url(self, course):
"""
diff --git a/lms/djangoapps/instructor/tests/test_services.py b/lms/djangoapps/instructor/tests/test_services.py
index 488c0d43a6..7080b58bba 100644
--- a/lms/djangoapps/instructor/tests/test_services.py
+++ b/lms/djangoapps/instructor/tests/test_services.py
@@ -5,9 +5,7 @@ import json
from unittest import mock
import pytest
-from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
from django.core.exceptions import ObjectDoesNotExist
-from edx_toggles.toggles.testutils import override_waffle_switch
from opaque_keys import InvalidKeyError
from common.djangoapps.student.models import CourseEnrollment
@@ -15,9 +13,8 @@ from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.instructor.access import allow_access
from lms.djangoapps.instructor.services import InstructorService
-from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
-from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
class InstructorServiceTests(SharedModuleStoreTestCase):
@@ -54,8 +51,8 @@ class InstructorServiceTests(SharedModuleStoreTestCase):
)
@mock.patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send')
- @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
- def test_reset_student_attempts_delete(self, mock_submit, _mock_signal):
+ @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True)
+ def test_reset_student_attempts_delete(self, mock_completion_task, _mock_signal):
"""
Test delete student state.
"""
@@ -64,22 +61,19 @@ class InstructorServiceTests(SharedModuleStoreTestCase):
assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id,
module_state_key=self.module_to_reset.module_state_key).count() == 1
- with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
- self.service.delete_student_attempt(
- self.student.username,
- str(self.course.id),
- str(self.subsection.location),
- requesting_user=self.student,
- )
+ self.service.delete_student_attempt(
+ self.student.username,
+ str(self.course.id),
+ str(self.subsection.location),
+ requesting_user=self.student,
+ )
# make sure the module has been deleted
assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id,
module_state_key=self.module_to_reset.module_state_key).count() == 0
- # Assert we send completion == 0.0 for both problems even though the second problem was never viewed
- assert mock_submit.call_count == 2
- mock_submit.assert_any_call(user=self.student, block_key=self.problem.location, completion=0.0)
- mock_submit.assert_any_call(user=self.student, block_key=self.problem_2.location, completion=0.0)
+ # Assert we send update completion with 0.0
+ mock_completion_task.assert_called_once_with((self.student.username, str(self.subsection.location), 0.0))
def test_reset_bad_content_id(self):
"""
@@ -120,128 +114,13 @@ class InstructorServiceTests(SharedModuleStoreTestCase):
)
assert result is None
- @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
- def test_complete_student_attempt_success(self, mock_submit):
+ @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True)
+ def test_complete_student_attempt_success(self, mock_completion_task):
"""
- Assert complete_student_attempt correctly publishes completion for all
- completable children of the given content_id
+ Assert update_exam_completion task is triggered
"""
- # Section, subsection, and unit are all aggregators and not completable so should
- # not be submitted.
- section = BlockFactory.create(parent=self.course, category='chapter')
- subsection = BlockFactory.create(parent=section, category='sequential')
- unit = BlockFactory.create(parent=subsection, category='vertical')
-
- # should both be submitted
- video = BlockFactory.create(parent=unit, category='video')
- problem = BlockFactory.create(parent=unit, category='problem')
-
- # Not a completable block
- BlockFactory.create(parent=unit, category='discussion')
-
- with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
- self.service.complete_student_attempt(self.student.username, str(subsection.location))
-
- # Only Completable leaf blocks should have completion published
- assert mock_submit.call_count == 2
- mock_submit.assert_any_call(user=self.student, block_key=video.location, completion=1.0)
- mock_submit.assert_any_call(user=self.student, block_key=problem.location, completion=1.0)
-
- @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
- def test_complete_student_attempt_split_test(self, mock_submit):
- """
- Asserts complete_student_attempt correctly publishes completion when a split test is involved
-
- This test case exists because we ran into a bug about the user_service not existing
- when a split_test existed inside of a subsection. Associated with this change was adding
- in the user state into the module before attempting completion and this ensures that is
- working properly.
- """
- partition = UserPartition(
- 0,
- 'first_partition',
- 'First Partition',
- [
- Group(0, 'alpha'),
- Group(1, 'beta')
- ]
- )
- course = CourseFactory.create(user_partitions=[partition])
- section = BlockFactory.create(parent=course, category='chapter')
- subsection = BlockFactory.create(parent=section, category='sequential')
-
- c0_url = course.id.make_usage_key('vertical', 'split_test_cond0')
- c1_url = course.id.make_usage_key('vertical', 'split_test_cond1')
- split_test = BlockFactory.create(
- parent=subsection,
- category='split_test',
- user_partition_id=0,
- group_id_to_child={'0': c0_url, '1': c1_url},
- )
-
- cond0vert = BlockFactory.create(parent=split_test, category='vertical', location=c0_url)
- BlockFactory.create(parent=cond0vert, category='video')
- BlockFactory.create(parent=cond0vert, category='problem')
-
- cond1vert = BlockFactory.create(parent=split_test, category='vertical', location=c1_url)
- BlockFactory.create(parent=cond1vert, category='video')
- BlockFactory.create(parent=cond1vert, category='html')
-
- with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
- self.service.complete_student_attempt(self.student.username, str(subsection.location))
-
- # Only the group the user was assigned to should have completion published.
- # Either cond0vert's children or cond1vert's children
- assert mock_submit.call_count == 2
-
- @mock.patch('lms.djangoapps.instructor.tasks.log.error')
- def test_complete_student_attempt_bad_user(self, mock_logger):
- """
- Assert complete_student_attempt with a bad user raises error and returns None
- """
- username = 'bad_user'
- block_id = str(self.problem.location)
- self.service.complete_student_attempt(username, block_id)
- mock_logger.assert_called_once_with(
- self.complete_error_prefix.format(user=username, content_id=block_id) + 'User does not exist!'
- )
-
- @mock.patch('lms.djangoapps.instructor.tasks.log.error')
- def test_complete_student_attempt_bad_content_id(self, mock_logger):
- """
- Assert complete_student_attempt with a bad content_id raises error and returns None
- """
- username = self.student.username
- self.service.complete_student_attempt(username, 'foo/bar/baz')
- mock_logger.assert_called_once_with(
- self.complete_error_prefix.format(user=username, content_id='foo/bar/baz') + 'Invalid content_id!'
- )
-
- @mock.patch('lms.djangoapps.instructor.tasks.log.error')
- def test_complete_student_attempt_nonexisting_item(self, mock_logger):
- """
- Assert complete_student_attempt with nonexisting item in the modulestore
- raises error and returns None
- """
- username = self.student.username
- block = 'i4x://org.0/course_0/problem/fake_problem'
- self.service.complete_student_attempt(username, block)
- mock_logger.assert_called_once_with(
- self.complete_error_prefix.format(user=username, content_id=block) + 'Block not found in the modulestore!'
- )
-
- @mock.patch('lms.djangoapps.instructor.tasks.log.error')
- def test_complete_student_attempt_failed_module(self, mock_logger):
- """
- Assert complete_student_attempt with failed get_block raises error and returns None
- """
- username = self.student.username
- with mock.patch('lms.djangoapps.instructor.tasks.get_block_for_descriptor', return_value=None):
- self.service.complete_student_attempt(username, str(self.course.location))
- mock_logger.assert_called_once_with(
- self.complete_error_prefix.format(user=username, content_id=self.course.location) +
- 'Block unable to be created from descriptor!'
- )
+ self.service.complete_student_attempt(self.student.username, str(self.subsection.location))
+ mock_completion_task.assert_called_once_with((self.student.username, str(self.subsection.location), 1.0))
def test_is_user_staff(self):
"""
diff --git a/lms/djangoapps/instructor/tests/test_spoc_gradebook.py b/lms/djangoapps/instructor/tests/test_spoc_gradebook.py
index bf574e8f50..221ed3603e 100644
--- a/lms/djangoapps/instructor/tests/test_spoc_gradebook.py
+++ b/lms/djangoapps/instructor/tests/test_spoc_gradebook.py
@@ -56,7 +56,7 @@ class TestGradebook(SharedModuleStoreTestCase):
super().setUp()
instructor = AdminFactory.create()
- self.client.login(username=instructor.username, password='test')
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
self.users = [UserFactory.create() for _ in range(USER_COUNT)]
for user in self.users:
CourseEnrollmentFactory.create(user=user, course_id=self.course.id)
diff --git a/lms/djangoapps/instructor/tests/test_tasks.py b/lms/djangoapps/instructor/tests/test_tasks.py
new file mode 100644
index 0000000000..4c6f9016f4
--- /dev/null
+++ b/lms/djangoapps/instructor/tests/test_tasks.py
@@ -0,0 +1,189 @@
+"""
+Tests for tasks.py
+"""
+import json
+from unittest import mock
+
+from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH
+from edx_toggles.toggles.testutils import override_waffle_switch
+
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.student.tests.factories import UserFactory
+from lms.djangoapps.courseware.models import StudentModule
+from lms.djangoapps.instructor.tasks import update_exam_completion_task
+from xmodule.modulestore.tests.django_utils import \
+ SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.factories import ( # lint-amnesty, pylint: disable=wrong-import-order
+ BlockFactory,
+ CourseFactory
+)
+from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order
+
+
+class UpdateCompletionTests(SharedModuleStoreTestCase):
+ """
+ Test the update_exam_completion_task
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.email = 'escalation@test.com'
+ cls.course = CourseFactory.create(proctoring_escalation_email=cls.email)
+ cls.section = BlockFactory.create(parent=cls.course, category='chapter')
+ cls.subsection = BlockFactory.create(parent=cls.section, category='sequential')
+ cls.unit = BlockFactory.create(parent=cls.subsection, category='vertical')
+ cls.problem = BlockFactory.create(parent=cls.unit, category='problem')
+ cls.unit_2 = BlockFactory.create(parent=cls.subsection, category='vertical')
+ cls.problem_2 = BlockFactory.create(parent=cls.unit_2, category='problem')
+ cls.complete_error_prefix = ('Error occurred while attempting to complete student attempt for '
+ 'user {user} for content_id {content_id}. ')
+
+ def setUp(self):
+ super().setUp()
+
+ self.student = UserFactory()
+ CourseEnrollment.enroll(self.student, self.course.id)
+
+ self.module_to_reset = StudentModule.objects.create(
+ student=self.student,
+ course_id=self.course.id,
+ module_state_key=self.problem.location,
+ state=json.dumps({'attempts': 2}),
+ )
+
+ @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
+ def test_update_completion_success(self, mock_submit):
+ """
+ Assert correctly publishes completion for all
+ completable children of the given content_id
+ """
+ # Section, subsection, and unit are all aggregators and not completable so should
+ # not be submitted.
+ section = BlockFactory.create(parent=self.course, category='chapter')
+ subsection = BlockFactory.create(parent=section, category='sequential')
+ unit = BlockFactory.create(parent=subsection, category='vertical')
+
+ # should both be submitted
+ video = BlockFactory.create(parent=unit, category='video')
+ problem = BlockFactory.create(parent=unit, category='problem')
+
+ # Not a completable block
+ BlockFactory.create(parent=unit, category='discussion')
+
+ with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
+ update_exam_completion_task(self.student.username, str(subsection.location), 1.0)
+
+ # Only Completable leaf blocks should have completion published
+ assert mock_submit.call_count == 2
+ mock_submit.assert_any_call(user=self.student, block_key=video.location, completion=1.0)
+ mock_submit.assert_any_call(user=self.student, block_key=problem.location, completion=1.0)
+
+ @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
+ def test_update_completion_delete(self, mock_submit):
+ """
+ Test update completion with a value of 0.0
+ """
+ with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
+ update_exam_completion_task(self.student.username, str(self.subsection.location), 0.0)
+
+ # Assert we send completion == 0.0 for both problems
+ assert mock_submit.call_count == 2
+ mock_submit.assert_any_call(user=self.student, block_key=self.problem.location, completion=0.0)
+ mock_submit.assert_any_call(user=self.student, block_key=self.problem_2.location, completion=0.0)
+
+ @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion')
+ def test_update_completion_split_test(self, mock_submit):
+ """
+ Asserts correctly publishes completion when a split test is involved
+
+ This test case exists because we ran into a bug about the user_service not existing
+ when a split_test existed inside of a subsection. Associated with this change was adding
+ in the user state into the module before attempting completion and this ensures that is
+ working properly.
+ """
+ partition = UserPartition(
+ 0,
+ 'first_partition',
+ 'First Partition',
+ [
+ Group(0, 'alpha'),
+ Group(1, 'beta')
+ ]
+ )
+ course = CourseFactory.create(user_partitions=[partition])
+ section = BlockFactory.create(parent=course, category='chapter')
+ subsection = BlockFactory.create(parent=section, category='sequential')
+
+ c0_url = course.id.make_usage_key('vertical', 'split_test_cond0')
+ c1_url = course.id.make_usage_key('vertical', 'split_test_cond1')
+ split_test = BlockFactory.create(
+ parent=subsection,
+ category='split_test',
+ user_partition_id=0,
+ group_id_to_child={'0': c0_url, '1': c1_url},
+ )
+
+ cond0vert = BlockFactory.create(parent=split_test, category='vertical', location=c0_url)
+ BlockFactory.create(parent=cond0vert, category='video')
+ BlockFactory.create(parent=cond0vert, category='problem')
+
+ cond1vert = BlockFactory.create(parent=split_test, category='vertical', location=c1_url)
+ BlockFactory.create(parent=cond1vert, category='video')
+ BlockFactory.create(parent=cond1vert, category='html')
+
+ with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True):
+ update_exam_completion_task(self.student.username, str(subsection.location), 1.0)
+
+ # Only the group the user was assigned to should have completion published.
+ # Either cond0vert's children or cond1vert's children
+ assert mock_submit.call_count == 2
+
+ @mock.patch('lms.djangoapps.instructor.tasks.log.error')
+ def test_update_completion_bad_user(self, mock_logger):
+ """
+ Assert a bad user raises error and returns None
+ """
+ username = 'bad_user'
+ block_id = str(self.problem.location)
+ update_exam_completion_task(username, block_id, 1.0)
+ mock_logger.assert_called_once_with(
+ self.complete_error_prefix.format(user=username, content_id=block_id) + 'User does not exist!'
+ )
+
+ @mock.patch('lms.djangoapps.instructor.tasks.log.error')
+ def test_update_completion_bad_content_id(self, mock_logger):
+ """
+ Assert a bad content_id raises error and returns None
+ """
+ username = self.student.username
+ update_exam_completion_task(username, 'foo/bar/baz', 1.0)
+ mock_logger.assert_called_once_with(
+ self.complete_error_prefix.format(user=username, content_id='foo/bar/baz') + 'Invalid content_id!'
+ )
+
+ @mock.patch('lms.djangoapps.instructor.tasks.log.error')
+ def test_update_completion_nonexisting_item(self, mock_logger):
+ """
+ Assert nonexisting item in the modulestore
+ raises error and returns None
+ """
+ username = self.student.username
+ block = 'i4x://org.0/course_0/problem/fake_problem'
+ update_exam_completion_task(username, block, 1.0)
+ mock_logger.assert_called_once_with(
+ self.complete_error_prefix.format(user=username, content_id=block) + 'Block not found in the modulestore!'
+ )
+
+ @mock.patch('lms.djangoapps.instructor.tasks.log.error')
+ def test_update_completion_failed_module(self, mock_logger):
+ """
+ Assert failed get_block raises error and returns None
+ """
+ username = self.student.username
+ with mock.patch('lms.djangoapps.instructor.tasks.get_block_for_descriptor', return_value=None):
+ update_exam_completion_task(username, str(self.course.location), 1.0)
+ mock_logger.assert_called_once_with(
+ self.complete_error_prefix.format(user=username, content_id=self.course.location) +
+ 'Block unable to be created from descriptor!'
+ )
diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
index bca16977a9..db455f0258 100644
--- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
+++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py
@@ -85,7 +85,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
self.course_mode.save()
# Create instructor account
self.instructor = AdminFactory.create()
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
# URL for instructor dash
self.url = reverse('instructor_dashboard', kwargs={'course_id': str(self.course.id)})
@@ -169,7 +169,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
org=self.course.id.org
)
set_course_cohorted(self.course.id, True)
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url).content.decode('utf-8')
self.assertEqual(discussion_section in response, is_discussion_tab_available)
@@ -192,7 +192,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
with override_waffle_flag(OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG, is_legacy_discussion_setting_enabled):
user = UserFactory.create(is_staff=True)
set_course_cohorted(self.course.id, True)
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url).content.decode('utf-8')
self.assertEqual(discussion_section in response, is_discussion_tab_available)
@@ -224,7 +224,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
role=access_role,
org=self.course.id.org
)
- self.client.login(username=user.username, password="test")
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
if can_access:
self.assertContains(response, download_section)
@@ -244,7 +244,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT
role='data_researcher',
org=self.course.id.org
)
- self.client.login(username=user.username, password="test")
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
matches = re.findall(
rb'
% endif
- % if reset_button:
+ % if attempts_used and reset_button:
diff --git a/lms/urls.py b/lms/urls.py
index dc673054df..ca6a240eab 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -227,11 +227,6 @@ if settings.FEATURES.get('ENABLE_MOBILE_REST_API'):
re_path(r'^api/mobile/(?Pv(2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')),
]
-if settings.FEATURES.get('ENABLE_OPENBADGES'):
- urlpatterns += [
- path('api/badges/v1/', include(('lms.djangoapps.badges.api.urls', 'badges'), namespace='badges_api')),
- ]
-
urlpatterns += [
path('openassessment/fileupload/', include('openassessment.fileupload.urls')),
]
@@ -752,6 +747,16 @@ urlpatterns += [
),
]
+urlpatterns += [
+ re_path(
+ r'^courses/{}/courseware-search/enabled/$'.format(
+ settings.COURSE_ID_PATTERN,
+ ),
+ courseware_views.courseware_mfe_search_enabled,
+ name='courseware_search_enabled_view',
+ ),
+]
+
urlpatterns += [
re_path(
r'^courses/{}/lti_tab/(?P[^/]+)/$'.format(
diff --git a/mypy.ini b/mypy.ini
index 49d6f61b29..4d2f566fa3 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -2,7 +2,6 @@
follow_imports = silent
ignore_missing_imports = True
allow_untyped_globals = True
-exclude = tests
plugins =
mypy_django_plugin.main,
mypy_drf_plugin.main
@@ -11,8 +10,53 @@ files =
openedx/core/djangoapps/content_staging,
openedx/core/djangoapps/content_libraries,
openedx/core/djangoapps/xblock,
- openedx/core/types
+ openedx/core/types,
+ openedx/core/djangoapps/content_tagging
[mypy.plugins.django-stubs]
# content_staging only works with CMS; others work with either, so we run mypy with CMS settings.
django_settings_module = "cms.envs.test"
+
+# Selectively ignore packages known to be lacking type hints
+[mypy-bridgekeeper.*]
+ignore_missing_imports = True
+[mypy-celery.*]
+ignore_missing_imports = True
+[mypy-celery_utils.*]
+ignore_missing_imports = True
+[mypy-completion.*]
+ignore_missing_imports = True
+[mypy-crum.*]
+ignore_missing_imports = True
+[mypy-ddt.*]
+ignore_missing_imports = True
+[mypy-edx_api_doc_tools.*]
+ignore_missing_imports = True
+[mypy-edx_django_utils.*]
+ignore_missing_imports = True
+[mypy-edx_proctoring.*]
+ignore_missing_imports = True
+[mypy-edx_rest_api_client.*]
+ignore_missing_imports = True
+[mypy-edx_rest_framework_extensions.*]
+ignore_missing_imports = True
+[mypy-eventtracking.*]
+ignore_missing_imports = True
+[mypy-fs.*]
+ignore_missing_imports = True
+[mypy-model_utils.*]
+ignore_missing_imports = True
+[mypy-openedx_events.*]
+ignore_missing_imports = True
+[mypy-organizations.*]
+ignore_missing_imports = True
+[mypy-search.*]
+ignore_missing_imports = True
+[mypy-rules.*]
+ignore_missing_imports = True
+[mypy-web_fragments.*]
+ignore_missing_imports = True
+[mypy-webob.*]
+ignore_missing_imports = True
+[mypy-xblock.*]
+ignore_missing_imports = True
diff --git a/openedx/core/djangoapps/agreements/toggles.py b/openedx/core/djangoapps/agreements/toggles.py
new file mode 100644
index 0000000000..6d71abdf65
--- /dev/null
+++ b/openedx/core/djangoapps/agreements/toggles.py
@@ -0,0 +1,28 @@
+"""
+Toggle for lti pii acknowledgement feature.
+"""
+
+from opaque_keys.edx.keys import CourseKey
+from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
+
+# .. toggle_name: agreements.enable_lti_pii_acknowledgement
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Enables the lti pii acknowledgement feature for a course
+# .. toggle_warning: None
+# .. toggle_use_cases: temporary, open_edx
+# .. toggle_creation_date: 2023-10
+# .. toggle_target_removal_date: None
+# .. toggle_tickets: MST-2055
+
+
+ENABLE_LTI_PII_ACKNOWLEDGEMENT = CourseWaffleFlag('agreements.enable_lti_pii_acknowledgement', __name__)
+
+
+def lti_pii_acknowledgment_enabled(course_key):
+ """
+ Returns a boolean if lti pii acknowledgements are enabled for a course.
+ """
+ if isinstance(course_key, str):
+ course_key = CourseKey.from_string(course_key)
+ return ENABLE_LTI_PII_ACKNOWLEDGEMENT.is_enabled(course_key)
diff --git a/openedx/core/djangoapps/cache_toolbox/middleware.py b/openedx/core/djangoapps/cache_toolbox/middleware.py
index 4ba2162fe3..9d1ea2bf06 100644
--- a/openedx/core/djangoapps/cache_toolbox/middleware.py
+++ b/openedx/core/djangoapps/cache_toolbox/middleware.py
@@ -95,6 +95,7 @@ from django.contrib.auth.middleware import AuthenticationMiddleware
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
from django.utils.crypto import constant_time_compare
from django.utils.deprecation import MiddlewareMixin
+from edx_django_utils.monitoring import set_custom_attribute
from openedx.core.djangoapps.safe_sessions.middleware import SafeSessionMiddleware, _mark_cookie_for_deletion
@@ -112,6 +113,7 @@ class CacheBackedAuthenticationMiddleware(AuthenticationMiddleware, MiddlewareMi
super().__init__(*args, **kwargs)
def process_request(self, request):
+ set_custom_attribute('DEFAULT_HASHING_ALGORITHM', settings.DEFAULT_HASHING_ALGORITHM)
try:
# Try and construct a User instance from data stored in the cache
session_user_id = SafeSessionMiddleware.get_user_id_from_session(request)
@@ -141,9 +143,29 @@ class CacheBackedAuthenticationMiddleware(AuthenticationMiddleware, MiddlewareMi
auto_auth_enabled = settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING', False)
if not auto_auth_enabled and hasattr(request.user, 'get_session_auth_hash'):
session_hash = request.session.get(HASH_SESSION_KEY)
- if not (session_hash and constant_time_compare(session_hash, request.user.get_session_auth_hash())):
- # The session hash has changed due to a password
- # change. Log the user out.
- request.session.flush()
- request.user = AnonymousUser()
- _mark_cookie_for_deletion(request)
+ session_hash_verified = session_hash and constant_time_compare(
+ session_hash, request.user.get_session_auth_hash())
+
+ # session hash is verified from the default algo, so skip legacy check
+ if session_hash_verified:
+ set_custom_attribute('session_hash_verified', "default")
+ return
+
+ if (
+ session_hash and
+ hasattr(request.user, '_legacy_get_session_auth_hash') and
+ constant_time_compare(
+ session_hash,
+ request.user._legacy_get_session_auth_hash() # pylint: disable=protected-access
+ )
+ ):
+ # session hash is verified from legacy hashing algorithm.
+ set_custom_attribute('session_hash_verified', "fallback")
+ return
+
+ # The session hash has changed due to a password
+ # change. Log the user out.
+ request.session.flush()
+ request.user = AnonymousUser()
+ _mark_cookie_for_deletion(request)
+ set_custom_attribute('failed_session_verification', True)
diff --git a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py
index 775229d42b..c324cf6c52 100644
--- a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py
+++ b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py
@@ -1,17 +1,18 @@
"""Tests for cached authentication middleware."""
-from unittest.mock import patch
+from unittest.mock import call, patch
+import django
from django.conf import settings
-from django.contrib.auth.models import User, AnonymousUser # lint-amnesty, pylint: disable=imported-auth-user
-from django.urls import reverse
-from django.test import TestCase
from django.contrib.auth import SESSION_KEY
+from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
from django.http import HttpResponse, SimpleCookie
+from django.test import TestCase
+from django.urls import reverse
+from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.cache_toolbox.middleware import CacheBackedAuthenticationMiddleware
from openedx.core.djangoapps.safe_sessions.middleware import SafeCookieData, SafeSessionMiddleware
-from openedx.core.djangolib.testing.utils import skip_unless_cms, skip_unless_lms, get_mock_request
-from common.djangoapps.student.tests.factories import UserFactory
+from openedx.core.djangolib.testing.utils import get_mock_request, skip_unless_cms, skip_unless_lms
class CachedAuthMiddlewareTestCase(TestCase):
@@ -36,9 +37,68 @@ class CachedAuthMiddlewareTestCase(TestCase):
"""
response = self.client.get(test_url)
assert response.status_code == 200
- with patch.object(User, 'get_session_auth_hash', return_value='abc123'):
- response = self.client.get(test_url)
- self.assertRedirects(response, redirect_url, target_status_code=target_status_code)
+
+ with patch(
+ "openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute"
+ ) as mock_set_custom_attribute:
+ with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True):
+ # Django 3.2 has _legacy_get_session_auth_hash, and Django 4 does not
+ # Remove once we reach Django 4
+ if hasattr(User, '_legacy_get_session_auth_hash'):
+ with patch.object(User, '_legacy_get_session_auth_hash', return_value='abc123'):
+ response = self.client.get(test_url)
+ else:
+ response = self.client.get(test_url)
+
+ self.assertRedirects(response, redirect_url, target_status_code=target_status_code)
+ mock_set_custom_attribute.assert_any_call('failed_session_verification', True)
+
+ def _test_custom_attribute_after_changing_hash(self, test_url, mock_set_custom_attribute):
+ """verify that set_custom_attribute is called with expected values"""
+ password = 'test-password'
+
+ # Test DEFAULT_HASHING_ALGORITHM of 'sha1' for both login and client get
+ with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
+ self.client.login(username=self.user.username, password=password)
+ self.client.get(test_url)
+ # For Django 3.2, the setting 'sha1' applies and is the "default".
+ # For Django 4, the setting no longer applies, and 'sha256' will be used for both as the "default".
+ mock_set_custom_attribute.assert_has_calls([
+ call('DEFAULT_HASHING_ALGORITHM', 'sha1'),
+ call('session_hash_verified', "default"),
+ ])
+ mock_set_custom_attribute.reset_mock()
+
+ # Test DEFAULT_HASHING_ALGORITHM of 'sha1' for login and switch to 'sha256' for client get.
+ with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'):
+ self.client.login(username=self.user.username, password=password)
+ with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'):
+ self.client.get(test_url)
+ if django.VERSION < (4, 0):
+ # For Django 3.2, the setting 'sha1' applies to login, and uses 'she256' for client get,
+ # and should "fallback" to 'sha1".
+ mock_set_custom_attribute.assert_has_calls([
+ call('DEFAULT_HASHING_ALGORITHM', 'sha256'),
+ call('session_hash_verified', "fallback"),
+ ])
+ else:
+ # For Django 4, the setting no longer applies, and again 'sha256' will be used for both as the "default".
+ mock_set_custom_attribute.assert_has_calls([
+ call('DEFAULT_HASHING_ALGORITHM', 'sha256'),
+ call('session_hash_verified', "default"),
+ ])
+ mock_set_custom_attribute.reset_mock()
+
+ # Test DEFAULT_HASHING_ALGORITHM of 'sha256' for both login and client get
+ with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'):
+ self.client.login(username=self.user.username, password=password)
+ self.client.get(test_url)
+ # For Django 3.2, the setting 'sha256' applies and is the "default".
+ # For Django 4, the setting no longer applies, and 'sha256' will be used for both as the "default".
+ mock_set_custom_attribute.assert_has_calls([
+ call('DEFAULT_HASHING_ALGORITHM', 'sha256'),
+ call('session_hash_verified', "default"),
+ ])
@skip_unless_lms
def test_session_change_lms(self):
@@ -53,6 +113,20 @@ class CachedAuthMiddlewareTestCase(TestCase):
# Studio login redirects to LMS login
self._test_change_session_hash(home_url, settings.LOGIN_URL + '?next=' + home_url, target_status_code=302)
+ @skip_unless_lms
+ @patch("openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute")
+ def test_custom_attribute_after_changing_hash_lms(self, mock_set_custom_attribute):
+ """Test set_custom_attribute is called with expected values in LMS"""
+ test_url = reverse('dashboard')
+ self._test_custom_attribute_after_changing_hash(test_url, mock_set_custom_attribute)
+
+ @skip_unless_cms
+ @patch("openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute")
+ def test_custom_attribute_after_changing_hash_cms(self, mock_set_custom_attribute):
+ """Test set_custom_attribute is called with expected values in CMS"""
+ test_url = reverse('home')
+ self._test_custom_attribute_after_changing_hash(test_url, mock_set_custom_attribute)
+
def test_user_logout_on_session_hash_change(self):
"""
Verify that if a user's session auth hash and the request's hash
@@ -75,8 +149,15 @@ class CachedAuthMiddlewareTestCase(TestCase):
assert self.client.response.cookies.get(settings.SESSION_COOKIE_NAME).value == session_id
assert self.client.response.cookies.get('edx-jwt-cookie-header-payload').value == 'test-jwt-payload'
- with patch.object(User, 'get_session_auth_hash', return_value='abc123'):
- CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request)
+ with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True):
+ # Django 3.2 has _legacy_get_session_auth_hash, and Django 4 does not
+ # Remove once we reach Django 4
+ if hasattr(User, '_legacy_get_session_auth_hash'):
+ with patch.object(User, '_legacy_get_session_auth_hash', return_value='abc123'):
+ CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request)
+
+ else:
+ CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request)
SafeSessionMiddleware(get_response=lambda request: None).process_response(
self.request, self.client.response
)
diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py
index f2760edf86..600b187f9d 100644
--- a/openedx/core/djangoapps/catalog/tests/factories.py
+++ b/openedx/core/djangoapps/catalog/tests/factories.py
@@ -236,7 +236,6 @@ class ProgramTypeFactory(DictFactoryBase):
class ProgramTypeAttrsFactory(DictFactoryBase):
uuid = factory.Faker('uuid4')
slug = factory.Faker('word')
- coaching_supported = False
class ProgramFactory(DictFactoryBase):
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py
index fefe1536a2..f166cee546 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py
@@ -546,14 +546,14 @@ class ContentLibraryXBlockUserStateTestMixin(ContentLibraryContentTestMixin):
@requires_blockstore
-class ContentLibraryXBlockUserStateBServiceTest(ContentLibraryXBlockUserStateTestMixin, TestCase):
+class ContentLibraryXBlockUserStateBServiceTest(ContentLibraryXBlockUserStateTestMixin, TestCase): # type: ignore[misc]
"""
Tests XBlock user state for XBlocks in a content library using the standalone Blockstore service.
"""
@requires_blockstore_app
-class ContentLibraryXBlockUserStateTest(
+class ContentLibraryXBlockUserStateTest( # type: ignore[misc]
ContentLibraryXBlockUserStateTestMixin,
BlockstoreAppTestMixin,
LiveServerTestCase,
diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
index bc351b6b95..00c4466b7d 100644
--- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
+++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py
@@ -364,7 +364,7 @@ class ClipboardTestCase(ModuleStoreTestCase):
response = nonstaff_client.get(olx_url)
assert response.status_code == 403
- def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool:
+ def assertXmlEqual(self, xml_str_a: str, xml_str_b: str):
""" Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """
a = ElementTree.canonicalize(xml_str_a, strip_text=True)
b = ElementTree.canonicalize(xml_str_b, strip_text=True)
diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py
index bc420b9084..a8630e29e5 100644
--- a/openedx/core/djangoapps/content_tagging/api.py
+++ b/openedx/core/djangoapps/content_tagging/api.py
@@ -3,46 +3,46 @@ Content Tagging APIs
"""
from __future__ import annotations
-from typing import Iterator, List, Type
+from typing import Iterator
import openedx_tagging.core.tagging.api as oel_tagging
-from django.db.models import QuerySet
-from opaque_keys.edx.keys import CourseKey, UsageKey
+from django.db.models import Q, QuerySet, Exists, OuterRef
from openedx_tagging.core.tagging.models import Taxonomy
from organizations.models import Organization
-from .models import ContentObjectTag, ContentTaxonomy, TaxonomyOrg
+from .models import ContentObjectTag, TaxonomyOrg
+from .types import ContentKey
def create_taxonomy(
name: str,
- description: str = None,
+ description: str | None = None,
enabled=True,
- required=False,
allow_multiple=False,
allow_free_text=False,
- taxonomy_class: Type = ContentTaxonomy,
+ orgs: list[Organization] | None = None,
) -> Taxonomy:
"""
Creates, saves, and returns a new Taxonomy with the given attributes.
-
- If `taxonomy_class` not provided, then uses ContentTaxonomy.
"""
- return oel_tagging.create_taxonomy(
+ taxonomy = oel_tagging.create_taxonomy(
name=name,
description=description,
enabled=enabled,
- required=required,
allow_multiple=allow_multiple,
allow_free_text=allow_free_text,
- taxonomy_class=taxonomy_class,
)
+ if orgs is not None:
+ set_taxonomy_orgs(taxonomy=taxonomy, all_orgs=False, orgs=orgs)
+
+ return taxonomy
+
def set_taxonomy_orgs(
taxonomy: Taxonomy,
all_orgs=False,
- orgs: List[Organization] = None,
+ orgs: list[Organization] | None = None,
relationship: TaxonomyOrg.RelType = TaxonomyOrg.RelType.OWNER,
):
"""
@@ -81,7 +81,7 @@ def set_taxonomy_orgs(
def get_taxonomies_for_org(
enabled=True,
- org_owner: Organization = None,
+ org_owner: Organization | None = None,
) -> QuerySet:
"""
Generates a list of the enabled Taxonomies available for the given org, sorted by name.
@@ -94,33 +94,39 @@ def get_taxonomies_for_org(
If you want the disabled Taxonomies, pass enabled=False.
If you want all Taxonomies (both enabled and disabled), pass enabled=None.
"""
- taxonomies = oel_tagging.get_taxonomies(enabled=enabled)
- return ContentTaxonomy.taxonomies_for_org(
- org=org_owner,
- queryset=taxonomies,
+ org_short_name = org_owner.short_name if org_owner else None
+ return oel_tagging.get_taxonomies(enabled=enabled).filter(
+ Exists(
+ TaxonomyOrg.get_relationships(
+ taxonomy=OuterRef("pk"),
+ rel_type=TaxonomyOrg.RelType.OWNER,
+ org_short_name=org_short_name,
+ )
+ )
)
def get_content_tags(
- object_id: str, taxonomy_id: str = None
+ object_key: ContentKey,
+ taxonomy_id: int | None = None,
) -> Iterator[ContentObjectTag]:
"""
Generates a list of content tags for a given object.
Pass taxonomy to limit the returned object_tags to a specific taxonomy.
"""
- for object_tag in oel_tagging.get_object_tags(
- object_id=object_id,
+ return oel_tagging.get_object_tags(
+ object_id=str(object_key),
taxonomy_id=taxonomy_id,
- ):
- yield ContentObjectTag.cast(object_tag)
+ object_tag_class=ContentObjectTag,
+ )
def tag_content_object(
+ object_key: ContentKey,
taxonomy: Taxonomy,
tags: list,
- object_id: CourseKey | UsageKey,
-) -> list[ContentObjectTag]:
+) -> Iterator[ContentObjectTag]:
"""
This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or
course).
@@ -136,14 +142,18 @@ def tag_content_object(
Raises ValueError if the proposed tags are invalid for this taxonomy.
Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags.
"""
- content_tags = []
- for object_tag in oel_tagging.tag_object(
+ if not taxonomy.system_defined:
+ # We require that this taxonomy is linked to the content object's "org" or linked to "all orgs" (None):
+ org_short_name = object_key.org # type: ignore
+ if not taxonomy.taxonomyorg_set.filter(Q(org__short_name=org_short_name) | Q(org=None)).exists():
+ raise ValueError(f"The specified Taxonomy is not enabled for the content object's org ({org_short_name})")
+ oel_tagging.tag_object(
taxonomy=taxonomy,
tags=tags,
- object_id=str(object_id),
- ):
- content_tags.append(ContentObjectTag.cast(object_tag))
- return content_tags
+ object_id=str(object_key),
+ object_tag_class=ContentObjectTag,
+ )
+ return get_content_tags(object_key, taxonomy_id=taxonomy.id)
# Expose the oel_tagging APIs
diff --git a/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py
new file mode 100644
index 0000000000..fa00df307c
--- /dev/null
+++ b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py
@@ -0,0 +1,54 @@
+# Generated by Django 3.2.21 on 2023-10-09 23:12
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ replaces = [
+ ('content_tagging', '0001_initial'),
+ ('content_tagging', '0002_system_defined_taxonomies'),
+ ('content_tagging', '0003_system_defined_fixture'),
+ ('content_tagging', '0004_system_defined_org'),
+ ('content_tagging', '0005_auto_20230830_1517'),
+ ('content_tagging', '0006_simplify_models'),
+ ]
+
+ initial = True
+
+ dependencies = [
+ ("oel_tagging", "0001_squashed"),
+ ('organizations', '0003_historicalorganizationcourse'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ContentObjectTag',
+ fields=[
+ ],
+ options={
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('oel_tagging.objecttag',),
+ ),
+ migrations.CreateModel(
+ name='TaxonomyOrg',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('rel_type', models.CharField(choices=[('OWN', 'owner')], default='OWN', max_length=3)),
+ ('org', models.ForeignKey(default=None, help_text='Organization that is related to this taxonomy.If None, then this taxonomy is related to all organizations.', null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.organization')),
+ ('taxonomy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_tagging.taxonomy')),
+ ],
+ ),
+ migrations.AddIndex(
+ model_name='taxonomyorg',
+ index=models.Index(fields=['taxonomy', 'rel_type'], name='content_tag_taxonom_b04dd1_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='taxonomyorg',
+ index=models.Index(fields=['taxonomy', 'rel_type', 'org'], name='content_tag_taxonom_70d60b_idx'),
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py
index 78b0baa949..14a3e6ace1 100644
--- a/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py
+++ b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
'indexes': [],
'constraints': [],
},
- bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.usersystemdefinedtaxonomy'),
+ bases=('oel_tagging.usersystemdefinedtaxonomy', ),
),
migrations.CreateModel(
name='ContentLanguageTaxonomy',
@@ -32,7 +32,7 @@ class Migration(migrations.Migration):
'indexes': [],
'constraints': [],
},
- bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.languagetaxonomy'),
+ bases=('oel_tagging.languagetaxonomy', ),
),
migrations.CreateModel(
name='ContentOrganizationTaxonomy',
@@ -43,7 +43,7 @@ class Migration(migrations.Migration):
'indexes': [],
'constraints': [],
},
- bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.modelsystemdefinedtaxonomy'),
+ bases=('oel_tagging.modelsystemdefinedtaxonomy', ),
),
migrations.CreateModel(
name='OrganizationModelObjectTag',
diff --git a/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py
index 7846b907c4..ff855482e1 100644
--- a/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py
+++ b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py
@@ -38,12 +38,6 @@ def load_system_defined_taxonomies(apps, schema_editor):
org_taxonomy.taxonomy_class = ContentOrganizationTaxonomy
org_taxonomy.save()
- # Adding taxonomy class to the language taxonomy
- language_taxonomy = Taxonomy.objects.get(id=-1)
- ContentLanguageTaxonomy = apps.get_model("content_tagging", "ContentLanguageTaxonomy")
- language_taxonomy.taxonomy_class = ContentLanguageTaxonomy
- language_taxonomy.save()
-
def revert_system_defined_taxonomies(apps, schema_editor):
"""
diff --git a/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py
index 852d67ae4a..a60ef381cd 100644
--- a/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py
+++ b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py
@@ -6,8 +6,9 @@ def load_system_defined_org_taxonomies(apps, _schema_editor):
Associates the system defined taxonomy Language (id=-1) to all orgs and
removes the ContentOrganizationTaxonomy (id=-3) from the database
"""
- TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg")
- TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None)
+ # Disabled for now as the way that this taxonomy is created has changed.
+ # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg")
+ # TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None)
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
Taxonomy.objects.get(id=-3).delete()
@@ -20,8 +21,8 @@ def revert_system_defined_org_taxonomies(apps, _schema_editor):
Deletes association of system defined taxonomy Language (id=-1) to all orgs and
creates the ContentOrganizationTaxonomy (id=-3) in the database
"""
- TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg")
- TaxonomyOrg.objects.get(id=-1).delete()
+ # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg")
+ # TaxonomyOrg.objects.get(id=-1).delete()
Taxonomy = apps.get_model("oel_tagging", "Taxonomy")
org_taxonomy = Taxonomy(
diff --git a/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py
new file mode 100644
index 0000000000..7e8eb99ee7
--- /dev/null
+++ b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.21 on 2023-09-29 23:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('content_tagging', '0005_auto_20230830_1517'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='ContentAuthorTaxonomy',
+ ),
+ migrations.DeleteModel(
+ name='ContentLanguageTaxonomy',
+ ),
+ migrations.DeleteModel(
+ name='ContentTaxonomy',
+ ),
+ ]
diff --git a/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py
new file mode 100644
index 0000000000..0a48016ca2
--- /dev/null
+++ b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py
@@ -0,0 +1,28 @@
+from django.db import migrations
+
+
+def mark_language_taxonomy_as_all_orgs(apps, _schema_editor):
+ """
+ Associates the system defined taxonomy Language (id=-1) to all orgs.
+ """
+ TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg")
+ TaxonomyOrg.objects.update_or_create(taxonomy_id=-1, defaults={"org": None})
+
+
+def revert_mark_language_taxonomy_as_all_orgs(apps, _schema_editor):
+ """
+ Deletes association of system defined taxonomy Language (id=-1) to all orgs.
+ """
+ TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg")
+ TaxonomyOrg.objects.get(taxonomy_id=-1, org=None).delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('content_tagging', '0001_squashed'),
+ ("oel_tagging", "0012_language_taxonomy"),
+ ]
+
+ operations = [
+ migrations.RunPython(mark_language_taxonomy_as_all_orgs, revert_mark_language_taxonomy_as_all_orgs),
+ ]
diff --git a/openedx/core/djangoapps/content_tagging/models/__init__.py b/openedx/core/djangoapps/content_tagging/models/__init__.py
index 4606c5b853..f330b8ae81 100644
--- a/openedx/core/djangoapps/content_tagging/models/__init__.py
+++ b/openedx/core/djangoapps/content_tagging/models/__init__.py
@@ -4,9 +4,4 @@ Content Tagging and System defined models
from .base import (
TaxonomyOrg,
ContentObjectTag,
- ContentTaxonomy,
-)
-from .system_defined import (
- ContentLanguageTaxonomy,
- ContentAuthorTaxonomy,
)
diff --git a/openedx/core/djangoapps/content_tagging/models/base.py b/openedx/core/djangoapps/content_tagging/models/base.py
index 38dcfb5b75..77061e4611 100644
--- a/openedx/core/djangoapps/content_tagging/models/base.py
+++ b/openedx/core/djangoapps/content_tagging/models/base.py
@@ -3,12 +3,12 @@ Content Tagging models
"""
from __future__ import annotations
+from django.core.exceptions import ValidationError
from django.db import models
-from django.db.models import Exists, OuterRef, Q, QuerySet
+from django.db.models import Q, QuerySet
from django.utils.translation import gettext as _
from opaque_keys import InvalidKeyError
-from opaque_keys.edx.keys import LearningContextKey
-from opaque_keys.edx.locator import BlockUsageLocator
+from opaque_keys.edx.keys import LearningContextKey, UsageKey
from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy
from organizations.models import Organization
@@ -91,7 +91,7 @@ class ContentObjectTag(ObjectTag):
proxy = True
@property
- def object_key(self) -> BlockUsageLocator | LearningContextKey:
+ def object_key(self) -> UsageKey | LearningContextKey:
"""
Returns the object ID parsed as a UsageKey or LearningContextKey.
Raises InvalidKeyError object_id cannot be parse into one of those key types.
@@ -99,75 +99,14 @@ class ContentObjectTag(ObjectTag):
Returns None if there's no object_id.
"""
try:
- return LearningContextKey.from_string(str(self.object_id))
+ return LearningContextKey.from_string(self.object_id)
except InvalidKeyError:
- return BlockUsageLocator.from_string(str(self.object_id))
+ return UsageKey.from_string(self.object_id)
-
-class ContentTaxonomyMixin:
- """
- Taxonomy which can only tag Content objects (e.g. XBlocks or Courses) via ContentObjectTag.
-
- Also ensures a valid TaxonomyOrg owner relationship with the content object.
- """
-
- @classmethod
- def taxonomies_for_org(
- cls,
- queryset: QuerySet,
- org: Organization | None = None,
- ) -> QuerySet:
- """
- Filters the given QuerySet to those ContentTaxonomies which are available for the given organization.
-
- If no `org` is provided, then only ContentTaxonomies available to all organizations are returned.
- If `org` is provided, then ContentTaxonomies available to this organizations are also returned.
- """
- org_short_name = org.short_name if org else None
- return queryset.filter(
- Exists(
- TaxonomyOrg.get_relationships(
- taxonomy=OuterRef("pk"),
- rel_type=TaxonomyOrg.RelType.OWNER,
- org_short_name=org_short_name,
- )
- )
- )
-
- def _check_object(self, object_tag: ObjectTag) -> bool:
- """
- Returns True if this ObjectTag has a valid object_id.
- """
- content_tag = ContentObjectTag.cast(object_tag)
+ def clean(self):
+ super().clean()
+ # Make sure that object_id is a valid key
try:
- content_tag.object_key
- except InvalidKeyError:
- return False
- return super()._check_object(content_tag)
-
- def _check_taxonomy(self, object_tag: ObjectTag) -> bool:
- """
- Returns True if this taxonomy is owned by the tag's org.
- """
- content_tag = ContentObjectTag.cast(object_tag)
- try:
- object_key = content_tag.object_key
- except InvalidKeyError:
- return False
- if not TaxonomyOrg.get_relationships(
- taxonomy=self,
- rel_type=TaxonomyOrg.RelType.OWNER,
- org_short_name=object_key.org,
- ).exists():
- return False
- return super()._check_taxonomy(content_tag)
-
-
-class ContentTaxonomy(ContentTaxonomyMixin, Taxonomy):
- """
- Taxonomy that accepts ContentTags,
- and ensures a valid TaxonomyOrg owner relationship with the content object.
- """
-
- class Meta:
- proxy = True
+ self.object_key
+ except InvalidKeyError as err:
+ raise ValidationError("object_id is not a valid opaque key string.") from err
diff --git a/openedx/core/djangoapps/content_tagging/models/system_defined.py b/openedx/core/djangoapps/content_tagging/models/system_defined.py
deleted file mode 100644
index 9948625455..0000000000
--- a/openedx/core/djangoapps/content_tagging/models/system_defined.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""
-System defined models
-"""
-from openedx_tagging.core.tagging.models import (
- UserSystemDefinedTaxonomy,
- LanguageTaxonomy,
-)
-
-from .base import ContentTaxonomyMixin
-
-
-class ContentLanguageTaxonomy(ContentTaxonomyMixin, LanguageTaxonomy):
- """
- Language system-defined taxonomy that accepts ContentTags
- """
-
- class Meta:
- proxy = True
-
-
-class ContentAuthorTaxonomy(ContentTaxonomyMixin, UserSystemDefinedTaxonomy):
- """
- Author system-defined taxonomy that accepts ContentTags
- """
-
- class Meta:
- proxy = True
diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py
index 9ad192f545..723a90d877 100644
--- a/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py
+++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py
@@ -2,20 +2,92 @@
API Filters for content tagging org
"""
+from django.db.models import Exists, OuterRef, Q
from rest_framework.filters import BaseFilterBackend
import openedx_tagging.core.tagging.rules as oel_tagging
+from organizations.models import Organization
+
+from ...rules import get_admin_orgs, get_user_orgs
+from ...models import TaxonomyOrg
class UserOrgFilterBackend(BaseFilterBackend):
"""
- Taxonomy admin can see all taxonomies
- Everyone else can see only enabled taxonomies
+ Filter taxonomies based on user's orgs roles
+ Taxonomy admin can see all taxonomies
+ Org staff can see all taxonomies from their orgs
+ Content creators and instructors can see enabled taxonomies avaliable to their orgs
"""
def filter_queryset(self, request, queryset, _):
if oel_tagging.is_taxonomy_admin(request.user):
return queryset
- return queryset.filter(enabled=True)
+ orgs = list(Organization.objects.all())
+ user_admin_orgs = get_admin_orgs(request.user, orgs)
+ user_orgs = get_user_orgs(request.user, orgs) # Orgs that the user is a content creator or instructor
+
+ if len(user_orgs) == 0 and len(user_admin_orgs) == 0:
+ return queryset.none()
+
+ return queryset.filter(
+ # Get enabled taxonomies available to all orgs, or from orgs that the user is
+ # a content creator or instructor
+ Q(
+ Exists(
+ TaxonomyOrg.objects
+ .filter(
+ taxonomy=OuterRef("pk"),
+ rel_type=TaxonomyOrg.RelType.OWNER,
+ )
+ .filter(
+ Q(org=None) |
+ Q(org__in=user_orgs)
+ )
+ ),
+ enabled=True,
+ ) |
+ # Get all taxonomies from orgs that the user is OrgStaff
+ Q(
+ Exists(
+ TaxonomyOrg.objects
+ .filter(taxonomy=OuterRef("pk"), rel_type=TaxonomyOrg.RelType.OWNER)
+ .filter(org__in=user_admin_orgs)
+ )
+ )
+ )
+
+
+class ObjectTagTaxonomyOrgFilterBackend(BaseFilterBackend):
+ """
+ Filter for ObjectTagViewSet to only show taxonomies that the user can view.
+ """
+
+ def filter_queryset(self, request, queryset, _):
+ if oel_tagging.is_taxonomy_admin(request.user):
+ return queryset
+
+ orgs = list(Organization.objects.all())
+ user_admin_orgs = get_admin_orgs(request.user, orgs)
+ user_orgs = get_user_orgs(request.user, orgs)
+ user_or_admin_orgs = list(set(user_orgs) | set(user_admin_orgs))
+
+ return queryset.filter(taxonomy__enabled=True).filter(
+ # Get ObjectTags from taxonomies available to all orgs, or from orgs that the user is
+ # a OrgStaff, content creator or instructor
+ Q(
+ Exists(
+ TaxonomyOrg.objects
+ .filter(
+ taxonomy=OuterRef("taxonomy_id"),
+ rel_type=TaxonomyOrg.RelType.OWNER,
+ )
+ .filter(
+ Q(org=None) |
+ Q(org__in=user_or_admin_orgs)
+ )
+ )
+ )
+ )
diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py
index 1771816a14..0a7ff92409 100644
--- a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py
+++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py
@@ -2,7 +2,7 @@
API Serializers for content tagging org
"""
-from rest_framework import serializers
+from rest_framework import serializers, fields
from openedx_tagging.core.tagging.rest_api.v1.serializers import (
TaxonomyListQueryParamsSerializer,
@@ -16,7 +16,7 @@ class TaxonomyOrgListQueryParamsSerializer(TaxonomyListQueryParamsSerializer):
Serializer for the query params for the GET view
"""
- org = serializers.SlugRelatedField(
+ org: fields.Field = serializers.SlugRelatedField(
slug_field="short_name",
queryset=Organization.objects.all(),
required=False,
diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py
index 2eafb933b4..d37b5df26c 100644
--- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py
+++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py
@@ -2,11 +2,13 @@
Tests tagging rest api views
"""
+from __future__ import annotations
+
from urllib.parse import parse_qs, urlparse
+import abc
import ddt
from django.contrib.auth import get_user_model
-from django.test.testcases import override_settings
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from openedx_tagging.core.tagging.models import Tag, Taxonomy
from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy
@@ -16,15 +18,30 @@ from rest_framework import status
from rest_framework.test import APITestCase
from common.djangoapps.student.auth import add_users, update_org_role
-from common.djangoapps.student.roles import CourseStaffRole, OrgContentCreatorRole
+from common.djangoapps.student.roles import (
+ CourseInstructorRole,
+ CourseStaffRole,
+ OrgContentCreatorRole,
+ OrgInstructorRole,
+ OrgLibraryUserRole,
+ OrgStaffRole,
+)
+from openedx.core.djangoapps.content_libraries.api import (
+ AccessLevel,
+ create_library,
+ COMPLEX,
+ set_library_user_permissions,
+)
from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg
from openedx.core.djangolib.testing.utils import skip_unless_cms
+from openedx.core.lib import blockstore_api
User = get_user_model()
TAXONOMY_ORG_LIST_URL = "/api/content_tagging/v1/taxonomies/"
TAXONOMY_ORG_DETAIL_URL = "/api/content_tagging/v1/taxonomies/{pk}/"
OBJECT_TAG_UPDATE_URL = "/api/content_tagging/v1/object_tags/{object_id}/?taxonomy={taxonomy_id}"
+TAXONOMY_TEMPLATE_URL = "/api/content_tagging/v1/taxonomies/import/{filename}"
def check_taxonomy(
@@ -33,8 +50,7 @@ def check_taxonomy(
name,
description=None,
enabled=True,
- required=False,
- allow_multiple=False,
+ allow_multiple=True,
allow_free_text=False,
system_defined=False,
visible_to_authors=True,
@@ -47,7 +63,6 @@ def check_taxonomy(
assert data["name"] == name
assert data["description"] == description
assert data["enabled"] == enabled
- assert data["required"] == required
assert data["allow_multiple"] == allow_multiple
assert data["allow_free_text"] == allow_free_text
assert data["system_defined"] == system_defined
@@ -58,29 +73,102 @@ class TestTaxonomyObjectsMixin:
"""
Sets up data for testing Content Taxonomies.
"""
+ def _setUp_orgs(self):
+ """
+ Create orgs for testing
+ """
+ self.orgA = Organization.objects.create(name="Organization A", short_name="orgA")
+ self.orgB = Organization.objects.create(name="Organization B", short_name="orgB")
+ self.orgX = Organization.objects.create(name="Organization X", short_name="orgX")
- def setUp(self):
- super().setUp()
+ def _setUp_courses(self):
+ """
+ Create courses for testing
+ """
+ self.courseA = CourseLocator("orgA", "101", "test")
+ self.courseB = CourseLocator("orgB", "101", "test")
+
+ def _setUp_library(self):
+ """
+ Create library for testing
+ """
+ self.collection = blockstore_api.create_collection("Test library collection")
+ self.content_libraryA = create_library(
+ collection_uuid=self.collection.uuid,
+ org=self.orgA,
+ slug="lib_a",
+ library_type=COMPLEX,
+ title="Library Org A",
+ description="This is a library from Org A",
+ allow_public_learning=False,
+ allow_public_read=False,
+ library_license="",
+ )
+
+ def _setUp_users(self):
+ """
+ Create users for testing
+ """
self.user = User.objects.create(
username="user",
email="user@example.com",
)
- self.userS = User.objects.create(
+ self.staff = User.objects.create(
username="staff",
email="staff@example.com",
is_staff=True,
)
- self.orgA = Organization.objects.create(name="Organization A", short_name="orgA")
- self.orgB = Organization.objects.create(name="Organization B", short_name="orgB")
- self.orgX = Organization.objects.create(name="Organization X", short_name="orgX")
-
- self.userA = User.objects.create(
- username="userA",
+ self.staffA = User.objects.create(
+ username="staffA",
email="userA@example.com",
)
- update_org_role(self.userS, OrgContentCreatorRole, self.userA, [self.orgA.short_name])
+ update_org_role(self.staff, OrgStaffRole, self.staffA, [self.orgA.short_name])
+ self.content_creatorA = User.objects.create(
+ username="content_creatorA",
+ email="content_creatorA@example.com",
+ )
+ update_org_role(self.staff, OrgContentCreatorRole, self.content_creatorA, [self.orgA.short_name])
+
+ self.instructorA = User.objects.create(
+ username="instructorA",
+ email="instructorA@example.com",
+ )
+ update_org_role(self.staff, OrgInstructorRole, self.instructorA, [self.orgA.short_name])
+
+ self.library_staffA = User.objects.create(
+ username="library_staffA",
+ email="library_staffA@example.com",
+ )
+ update_org_role(self.staff, OrgLibraryUserRole, self.library_staffA, [self.orgA.short_name])
+
+ self.course_instructorA = User.objects.create(
+ username="course_instructorA",
+ email="course_instructorA@example.com",
+ )
+ add_users(self.staff, CourseInstructorRole(self.courseA), self.course_instructorA)
+
+ self.course_staffA = User.objects.create(
+ username="course_staffA",
+ email="course_staffA@example.com",
+ )
+ add_users(self.staff, CourseStaffRole(self.courseA), self.course_staffA)
+
+ self.library_userA = User.objects.create(
+ username="library_userA",
+ email="library_userA@example.com",
+ )
+ set_library_user_permissions(
+ self.content_libraryA.key,
+ self.library_userA,
+ AccessLevel.READ_LEVEL
+ )
+
+ def _setUp_taxonomies(self):
+ """
+ Create taxonomies for testing
+ """
# Orphaned taxonomy
self.ot1 = Taxonomy.objects.create(name="ot1", enabled=True)
self.ot2 = Taxonomy.objects.create(name="ot2", enabled=False)
@@ -118,9 +206,7 @@ class TestTaxonomyObjectsMixin:
self.tA1 = Taxonomy.objects.create(name="tA1", enabled=True)
TaxonomyOrg.objects.create(
taxonomy=self.tA1,
- org=self.orgA,
- rel_type=TaxonomyOrg.RelType.OWNER,
- )
+ org=self.orgA, rel_type=TaxonomyOrg.RelType.OWNER,)
self.tA2 = Taxonomy.objects.create(name="tA2", enabled=False)
TaxonomyOrg.objects.create(
taxonomy=self.tA2,
@@ -143,92 +229,139 @@ class TestTaxonomyObjectsMixin:
)
# OrgA and OrgB taxonomy
- self.tC1 = Taxonomy.objects.create(name="tC1", enabled=True)
+ self.tBA1 = Taxonomy.objects.create(name="tBA1", enabled=True)
TaxonomyOrg.objects.create(
- taxonomy=self.tC1,
+ taxonomy=self.tBA1,
org=self.orgA,
rel_type=TaxonomyOrg.RelType.OWNER,
)
TaxonomyOrg.objects.create(
- taxonomy=self.tC1,
+ taxonomy=self.tBA1,
org=self.orgB,
rel_type=TaxonomyOrg.RelType.OWNER,
)
- self.tC2 = Taxonomy.objects.create(name="tC2", enabled=False)
+ self.tBA2 = Taxonomy.objects.create(name="tBA2", enabled=False)
TaxonomyOrg.objects.create(
- taxonomy=self.tC2,
+ taxonomy=self.tBA2,
org=self.orgA,
rel_type=TaxonomyOrg.RelType.OWNER,
)
TaxonomyOrg.objects.create(
- taxonomy=self.tC2,
+ taxonomy=self.tBA2,
org=self.orgB,
rel_type=TaxonomyOrg.RelType.OWNER,
)
+ def setUp(self):
+
+ super().setUp()
+
+ self._setUp_orgs()
+ self._setUp_courses()
+ self._setUp_library()
+ self._setUp_users()
+ self._setUp_taxonomies()
+
@skip_unless_cms
@ddt.ddt
-@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": True})
-class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase):
+class TestTaxonomyListCreateViewSet(TestTaxonomyObjectsMixin, APITestCase):
"""
- Test cases for TaxonomyViewSet when ENABLE_CREATOR_GROUP is True
+ Test cases for TaxonomyViewSet for list and create actions
"""
- @ddt.data(
- ("user", None, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")),
- ("userA", None, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")),
- ("userS", None, None, ("ot1", "ot2", "st1", "st2", "t1", "t2", "tA1", "tA2", "tB1", "tB2")),
- # Default page_size=10, and so "tC1" and "tC2" appear on the second page
- ("user", True, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")),
- ("userA", True, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")),
- ("userS", True, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")),
- ("user", False, None, ()),
- ("userA", False, None, ()),
- ("userS", False, None, ("ot2", "st2", "t2", "tA2", "tB2", "tC2")),
- ("user", None, "orgA", ("st1", "t1", "tA1", "tC1")),
- ("userA", None, "orgA", ("st1", "t1", "tA1", "tC1")),
- ("userS", None, "orgA", ("st1", "st2", "t1", "t2", "tA1", "tA2", "tC1", "tC2")),
- ("user", True, "orgA", ("st1", "t1", "tA1", "tC1")),
- ("userA", True, "orgA", ("st1", "t1", "tA1", "tC1")),
- ("userS", True, "orgA", ("st1", "t1", "tA1", "tC1")),
- ("user", False, "orgA", ()),
- ("userA", False, "orgA", ()),
- ("userS", False, "orgA", ("st2", "t2", "tA2", "tC2")),
- ("user", None, "orgX", ("st1", "t1")),
- ("userA", None, "orgX", ("st1", "t1")),
- ("userS", None, "orgX", ("st1", "st2", "t1", "t2")),
- ("user", True, "orgX", ("st1", "t1")),
- ("userA", True, "orgX", ("st1", "t1")),
- ("userS", True, "orgX", ("st1", "t1")),
- ("user", False, "orgX", ()),
- ("userA", False, "orgX", ()),
- ("userS", False, "orgX", ("st2", "t2")),
- )
- @ddt.unpack
- def test_list_taxonomy(self, user_attr, enabled_parameter, org_name, expected_taxonomies):
+ def _test_list_taxonomy(
+ self,
+ user_attr: str,
+ expected_taxonomies: list[str],
+ enabled_parameter: bool | None = None,
+ org_parameter: str | None = None
+ ) -> None:
+ """
+ Helper function to call the list endpoint and check the response
+ """
url = TAXONOMY_ORG_LIST_URL
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
# Set parameters cleaning empty values
- query_params = {k: v for k, v in {"enabled": enabled_parameter, "org": org_name}.items() if v is not None}
+ query_params = {k: v for k, v in {"enabled": enabled_parameter, "org": org_parameter}.items() if v is not None}
response = self.client.get(url, query_params, format="json")
assert response.status_code == status.HTTP_200_OK
self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies))
- def test_list_taxonomy_invalid_org(
- self,
- ):
+ def test_list_taxonomy_staff(self) -> None:
+ """
+ Tests that staff users see all taxonomies
+ """
+ # Default page_size=10, and so "tBA1" and "tBA2" appear on the second page
+ expected_taxonomies = ["ot1", "ot2", "st1", "st2", "t1", "t2", "tA1", "tA2", "tB1", "tB2"]
+ self._test_list_taxonomy(
+ user_attr="staff",
+ expected_taxonomies=expected_taxonomies,
+ )
+
+ @ddt.data(
+ "content_creatorA",
+ "instructorA",
+ "library_staffA",
+ "course_instructorA",
+ "course_staffA",
+ "library_userA",
+ )
+ def test_list_taxonomy_orgA(self, user_attr: str) -> None:
+ """
+ Tests that non staff users from orgA can see only enabled taxonomies from orgA and global taxonomies
+ """
+ expected_taxonomies = ["st1", "t1", "tA1", "tBA1"]
+ self._test_list_taxonomy(
+ user_attr=user_attr,
+ enabled_parameter=True,
+ expected_taxonomies=expected_taxonomies,
+ )
+
+ @ddt.data(
+ (True, ["ot1", "st1", "t1", "tA1", "tB1", "tBA1"]),
+ (False, ["ot2", "st2", "t2", "tA2", "tB2", "tBA2"]),
+ )
+ @ddt.unpack
+ def test_list_taxonomy_enabled_filter(self, enabled_parameter: bool, expected_taxonomies: list[str]) -> None:
+ """
+ Tests that the enabled filter works as expected
+ """
+ self._test_list_taxonomy(
+ user_attr="staff",
+ enabled_parameter=enabled_parameter,
+ expected_taxonomies=expected_taxonomies
+ )
+
+ @ddt.data(
+ ("orgA", ["st1", "st2", "t1", "t2", "tA1", "tA2", "tBA1", "tBA2"]),
+ ("orgB", ["st1", "st2", "t1", "t2", "tB1", "tB2", "tBA1", "tBA2"]),
+ ("orgX", ["st1", "st2", "t1", "t2"]),
+ )
+ @ddt.unpack
+ def test_list_taxonomy_org_filter(self, org_parameter: str, expected_taxonomies: list[str]) -> None:
+ """
+ Tests that the org filter works as expected
+ """
+ self._test_list_taxonomy(
+ user_attr="staff",
+ org_parameter=org_parameter,
+ expected_taxonomies=expected_taxonomies,
+ )
+
+ def test_list_taxonomy_invalid_org(self) -> None:
+ """
+ Tests that using an invalid org in the filter will raise BAD_REQUEST
+ """
url = TAXONOMY_ORG_LIST_URL
- self.client.force_authenticate(user=self.userS)
+ self.client.force_authenticate(user=self.staff)
- # Set parameters cleaning empty values
query_params = {"org": "invalidOrg"}
response = self.client.get(url, query_params, format="json")
@@ -236,30 +369,38 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase):
assert response.status_code == status.HTTP_400_BAD_REQUEST
@ddt.data(
- ("user", ("tA1", "tB1", "tC1"), None),
- ("userA", ("tA1", "tB1", "tC1"), None),
- ("userS", ("st2", "t1", "t2"), "3"),
+ ("user", (), None),
+ ("staffA", ["tA2", "tBA1", "tBA2"], None),
+ ("staff", ["st2", "t1", "t2"], "3"),
)
@ddt.unpack
- def test_list_taxonomy_pagination(self, user_attr, expected_taxonomies, expected_next_page):
+ def test_list_taxonomy_pagination(
+ self, user_attr: str, expected_taxonomies: list[str], expected_next_page: str | None
+ ) -> None:
+ """
+ Tests that the pagination works as expected
+ """
url = TAXONOMY_ORG_LIST_URL
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
query_params = {"page_size": 3, "page": 2}
response = self.client.get(url, query_params, format="json")
- assert response.status_code == status.HTTP_200_OK
- self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies))
- parsed_url = urlparse(response.data["next"])
+ assert response.status_code == status.HTTP_200_OK if len(expected_taxonomies) > 0 else status.HTTP_404_NOT_FOUND
+ if status.is_success(response.status_code):
+ self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies))
+ parsed_url = urlparse(response.data["next"])
- next_page = parse_qs(parsed_url.query).get("page", [None])[0]
- assert next_page == expected_next_page
+ next_page = parse_qs(parsed_url.query).get("page", [None])[0]
+ assert next_page == expected_next_page
- def test_list_invalid_page(self):
+ def test_list_invalid_page(self) -> None:
+ """
+ Tests that using an invalid page will raise NOT_FOUND
+ """
url = TAXONOMY_ORG_LIST_URL
self.client.force_authenticate(user=self.user)
@@ -271,86 +412,28 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase):
assert response.status_code == status.HTTP_404_NOT_FOUND
@ddt.data(
- (None, "ot1", status.HTTP_403_FORBIDDEN),
- (None, "ot2", status.HTTP_403_FORBIDDEN),
- (None, "st1", status.HTTP_403_FORBIDDEN),
- (None, "st2", status.HTTP_403_FORBIDDEN),
- (None, "t1", status.HTTP_403_FORBIDDEN),
- (None, "t2", status.HTTP_403_FORBIDDEN),
- (None, "tA1", status.HTTP_403_FORBIDDEN),
- (None, "tA2", status.HTTP_403_FORBIDDEN),
- (None, "tB1", status.HTTP_403_FORBIDDEN),
- (None, "tB2", status.HTTP_403_FORBIDDEN),
- (None, "tC1", status.HTTP_403_FORBIDDEN),
- (None, "tC2", status.HTTP_403_FORBIDDEN),
- ("user", "ot1", status.HTTP_200_OK),
- ("user", "ot2", status.HTTP_404_NOT_FOUND),
- ("user", "st1", status.HTTP_200_OK),
- ("user", "st2", status.HTTP_404_NOT_FOUND),
- ("user", "t1", status.HTTP_200_OK),
- ("user", "t2", status.HTTP_404_NOT_FOUND),
- ("user", "tA1", status.HTTP_200_OK),
- ("user", "tA2", status.HTTP_404_NOT_FOUND),
- ("user", "tB1", status.HTTP_200_OK),
- ("user", "tB2", status.HTTP_404_NOT_FOUND),
- ("user", "tC1", status.HTTP_200_OK),
- ("user", "tC2", status.HTTP_404_NOT_FOUND),
- ("userA", "ot1", status.HTTP_200_OK),
- ("userA", "ot2", status.HTTP_404_NOT_FOUND),
- ("userA", "st1", status.HTTP_200_OK),
- ("userA", "st2", status.HTTP_404_NOT_FOUND),
- ("userA", "t1", status.HTTP_200_OK),
- ("userA", "t2", status.HTTP_404_NOT_FOUND),
- ("userA", "tA1", status.HTTP_200_OK),
- ("userA", "tA2", status.HTTP_404_NOT_FOUND),
- ("userA", "tB1", status.HTTP_200_OK),
- ("userA", "tB2", status.HTTP_404_NOT_FOUND),
- ("userA", "tC1", status.HTTP_200_OK),
- ("userA", "tC2", status.HTTP_404_NOT_FOUND),
- ("userS", "ot1", status.HTTP_200_OK),
- ("userS", "ot2", status.HTTP_200_OK),
- ("userS", "st1", status.HTTP_200_OK),
- ("userS", "st2", status.HTTP_200_OK),
- ("userS", "t1", status.HTTP_200_OK),
- ("userS", "t2", status.HTTP_200_OK),
- ("userS", "tA1", status.HTTP_200_OK),
- ("userS", "tA2", status.HTTP_200_OK),
- ("userS", "tB1", status.HTTP_200_OK),
- ("userS", "tB2", status.HTTP_200_OK),
- ("userS", "tC1", status.HTTP_200_OK),
- ("userS", "tC2", status.HTTP_200_OK),
- )
- @ddt.unpack
- def test_detail_taxonomy(self, user_attr, taxonomy_attr, expected_status):
- taxonomy = getattr(self, taxonomy_attr)
-
- url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk)
-
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
-
- response = self.client.get(url)
- assert response.status_code == expected_status
-
- if status.is_success(expected_status):
- check_taxonomy(response.data, taxonomy.pk, **(TaxonomySerializer(taxonomy.cast()).data))
-
- @ddt.data(
- (None, status.HTTP_403_FORBIDDEN),
+ (None, status.HTTP_401_UNAUTHORIZED),
("user", status.HTTP_403_FORBIDDEN),
- ("userA", status.HTTP_403_FORBIDDEN),
- ("userS", status.HTTP_201_CREATED),
+ ("content_creatorA", status.HTTP_403_FORBIDDEN),
+ ("instructorA", status.HTTP_403_FORBIDDEN),
+ ("library_staffA", status.HTTP_403_FORBIDDEN),
+ ("course_instructorA", status.HTTP_403_FORBIDDEN),
+ ("course_staffA", status.HTTP_403_FORBIDDEN),
+ ("library_userA", status.HTTP_403_FORBIDDEN),
+ ("staffA", status.HTTP_201_CREATED),
+ ("staff", status.HTTP_201_CREATED),
)
@ddt.unpack
- def test_create_taxonomy(self, user_attr, expected_status):
+ def test_create_taxonomy(self, user_attr: str, expected_status: int) -> None:
+ """
+ Tests that only Taxonomy admins and org level admins can create taxonomies
+ """
url = TAXONOMY_ORG_LIST_URL
create_data = {
"name": "taxonomy_data",
"description": "This is a description",
"enabled": True,
- "required": True,
"allow_multiple": True,
}
@@ -369,73 +452,514 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase):
response = self.client.get(url)
check_taxonomy(response.data, response.data["id"], **create_data)
+ # Also checks if the taxonomy was associated with the org
+ if user_attr == "staffA":
+ assert TaxonomyOrg.objects.filter(taxonomy=response.data["id"], org=self.orgA).exists()
+
+
+@ddt.ddt
+class TestTaxonomyDetailExportMixin(TestTaxonomyObjectsMixin):
+ """
+ Test cases to be used with detail and export actions
+ """
+
+ @abc.abstractmethod
+ def _test_api_call(self, **_kwargs) -> None:
+ """
+ Helper function to call the detail/export endpoint and check the response
+ """
+
@ddt.data(
- (None, "ot1", status.HTTP_403_FORBIDDEN),
- (None, "ot2", status.HTTP_403_FORBIDDEN),
- (None, "st1", status.HTTP_403_FORBIDDEN),
- (None, "st2", status.HTTP_403_FORBIDDEN),
- (None, "t1", status.HTTP_403_FORBIDDEN),
- (None, "t2", status.HTTP_403_FORBIDDEN),
- (None, "tA1", status.HTTP_403_FORBIDDEN),
- (None, "tA2", status.HTTP_403_FORBIDDEN),
- (None, "tB1", status.HTTP_403_FORBIDDEN),
- (None, "tB2", status.HTTP_403_FORBIDDEN),
- (None, "tC1", status.HTTP_403_FORBIDDEN),
- (None, "tC2", status.HTTP_403_FORBIDDEN),
- ("user", "ot1", status.HTTP_403_FORBIDDEN),
- ("user", "ot2", status.HTTP_403_FORBIDDEN),
- ("user", "st1", status.HTTP_403_FORBIDDEN),
- ("user", "st2", status.HTTP_403_FORBIDDEN),
- ("user", "t1", status.HTTP_403_FORBIDDEN),
- ("user", "t2", status.HTTP_403_FORBIDDEN),
- ("user", "tA1", status.HTTP_403_FORBIDDEN),
- ("user", "tA2", status.HTTP_403_FORBIDDEN),
- ("user", "tB1", status.HTTP_403_FORBIDDEN),
- ("user", "tB2", status.HTTP_403_FORBIDDEN),
- ("user", "tC1", status.HTTP_403_FORBIDDEN),
- ("user", "tC2", status.HTTP_403_FORBIDDEN),
- ("userA", "ot1", status.HTTP_403_FORBIDDEN),
- ("userA", "ot2", status.HTTP_403_FORBIDDEN),
- ("userA", "st1", status.HTTP_403_FORBIDDEN),
- ("userA", "st2", status.HTTP_403_FORBIDDEN),
- ("userA", "t1", status.HTTP_403_FORBIDDEN),
- ("userA", "t2", status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", status.HTTP_403_FORBIDDEN),
- ("userA", "tA2", status.HTTP_403_FORBIDDEN),
- ("userA", "tB1", status.HTTP_403_FORBIDDEN),
- ("userA", "tB2", status.HTTP_403_FORBIDDEN),
- ("userA", "tC1", status.HTTP_403_FORBIDDEN),
- ("userA", "tC2", status.HTTP_403_FORBIDDEN),
- ("userS", "ot1", status.HTTP_200_OK),
- ("userS", "ot2", status.HTTP_200_OK),
- ("userS", "st1", status.HTTP_403_FORBIDDEN),
- ("userS", "st2", status.HTTP_403_FORBIDDEN),
- ("userS", "t1", status.HTTP_200_OK),
- ("userS", "t2", status.HTTP_200_OK),
- ("userS", "t1", status.HTTP_200_OK),
- ("userS", "t2", status.HTTP_200_OK),
- ("userS", "tA1", status.HTTP_200_OK),
- ("userS", "tA2", status.HTTP_200_OK),
- ("userS", "tB1", status.HTTP_200_OK),
- ("userS", "tB2", status.HTTP_200_OK),
- ("userS", "tC1", status.HTTP_200_OK),
- ("userS", "tC2", status.HTTP_200_OK),
+ "user",
+ "content_creatorA",
+ "instructorA",
+ "library_staffA",
+ "course_instructorA",
+ "course_staffA",
+ "library_userA",
+ )
+ def test_detail_taxonomy_all_org_enabled(self, user_attr: str) -> None:
+ """
+ Tests that everyone can see enabled global taxonomies
+ """
+ self._test_api_call(
+ user_attr=user_attr,
+ taxonomy_attr="t1",
+ expected_status=status.HTTP_200_OK,
+ reason="Everyone should see enabled global taxonomies",
+ )
+
+ @ddt.data(
+ ("content_creatorA", "tA1", "User with OrgContentCreatorRole(orgA) should see an enabled taxonomy from orgA"),
+ ("content_creatorA", "tBA1", "User with OrgContentCreatorRole(orgA) should see an enabled taxonomy from orgA"),
+ ("content_creatorA", "t1", "User with OrgContentCreatorRole(orgA) should see an enabled global taxonomy"),
+ ("instructorA", "tA1", "User with OrgInstructorRole(orgA) should see an enabled taxonomy from orgA"),
+ ("instructorA", "tBA1", "User with OrgInstructorRole(orgA) should see an enabled taxonomy from orgA"),
+ ("instructorA", "t1", "User with OrgInstructorRole(orgA) should see an enabled global taxonomy"),
+ ("library_staffA", "tA1", "User with OrgLibraryUserRole(orgA) should see an enabled taxonomy from orgA"),
+ ("library_staffA", "tBA1", "User with OrgLibraryUserRole(orgA) should see an enabled taxonomy from orgA"),
+ ("library_staffA", "t1", "User with OrgInstructorRole(orgA) should see an enabled global taxonomy"),
+ (
+ "course_instructorA",
+ "tA1",
+ "User with CourseInstructorRole in a course from orgA should see an enabled taxonomy from orgA"
+ ),
+ (
+ "course_instructorA",
+ "tBA1",
+ "User with CourseInstructorRole in a course from orgA should see an enabled taxonomy from orgA"
+ ),
+ (
+ "course_instructorA",
+ "t1",
+ "User with CourseInstructorRole in a course from orgA should see an enabled global taxonomy"
+ ),
+ (
+ "course_staffA",
+ "tA1",
+ "User with CourseStaffRole in a course from orgA should see an enabled taxonomy from orgA"
+ ),
+ (
+ "course_staffA",
+ "tBA1",
+ "User with CourseStaffRole in a course from orgA should see an enabled taxonomy from orgA"
+ ),
+ (
+ "course_staffA",
+ "t1",
+ "User with CourseStaffRole in a course from orgA should see an enabled global taxonomy"
+ ),
+ (
+ "library_userA",
+ "tA1",
+ "User with permission on a library from orgA should see an enabled taxonomy from orgA"
+ ),
+ (
+ "library_userA",
+ "tBA1",
+ "User with permission on a library from orgA should see an enabled taxonomy from orgA"
+ ),
+ (
+ "library_userA",
+ "t1",
+ "User with permission on a library from orgA should see an enabled global taxonomy"
+ ),
)
@ddt.unpack
- def test_update_taxonomy(self, user_attr, taxonomy_attr, expected_status):
+ def test_detail_taxonomy_org_user_see_enabled(self, user_attr: str, taxonomy_attr: str, reason: str) -> None:
+ """
+ Tests that org users (content creators and instructors) can see enabled global taxonomies and taxonomies
+ from their orgs
+ """
+ self._test_api_call(
+ user_attr=user_attr,
+ taxonomy_attr=taxonomy_attr,
+ expected_status=status.HTTP_200_OK,
+ reason=reason,
+ )
+
+ @ddt.data(
+ "tA2",
+ "tBA2",
+ )
+ def test_detail_taxonomy_org_admin_see_disabled(self, taxonomy_attr: str) -> None:
+ """
+ Tests that org admins can see disabled taxonomies from their orgs
+ """
+ self._test_api_call(
+ user_attr="staffA",
+ taxonomy_attr=taxonomy_attr,
+ expected_status=status.HTTP_200_OK,
+ reason="User with OrgContentCreatorRole(orgA) should see a disabled taxonomy from orgA",
+ )
+
+ @ddt.data(
+ "st2",
+ "t2",
+ )
+ def test_detail_taxonomy_org_admin_dont_see_disabled_global(self, taxonomy_attr: str) -> None:
+ """
+ Tests that org admins can't see disabled global taxonomies
+ """
+ self._test_api_call(
+ user_attr="staffA",
+ taxonomy_attr=taxonomy_attr,
+ expected_status=status.HTTP_404_NOT_FOUND,
+ reason="User with OrgContentCreatorRole(orgA) shouldn't see a disabled global taxonomy",
+ )
+
+ @ddt.data(
+ ("content_creatorA", "t2", "User with OrgContentCreatorRole(orgA) shouldn't see a disabled global taxonomy"),
+ ("instructorA", "tA2", "User with OrgInstructorRole(orgA) shouldn't see a disabled taxonomy from orgA"),
+ ("instructorA", "tBA2", "User with OrgInstructorRole(orgA) shouldn't see a disabled taxonomy from orgA"),
+ ("instructorA", "t2", "User with OrgInstructorRole(orgA) shouldn't see a disabled global taxonomy"),
+ ("library_staffA", "tA2", "User with OrgLibraryUserRole(orgA) shouldn't see a disabled taxonomy from orgA"),
+ ("library_staffA", "tBA2", "User with OrgLibraryUserRole(orgA) shouldn't see a disabled taxonomy from orgA"),
+ ("library_staffA", "t2", "User with OrgInstructorRole(orgA) shouldn't see a disabled global taxonomy"),
+ (
+ "course_instructorA",
+ "tA2",
+ "User with CourseInstructorRole in a course from orgA shouldn't see a disabled taxonomy from orgA"
+ ),
+ (
+ "course_instructorA",
+ "tBA2",
+ "User with CourseInstructorRole in a course from orgA shouldn't see a disabled taxonomy from orgA"
+ ),
+ (
+ "course_instructorA",
+ "t2",
+ "User with CourseInstructorRole in a course from orgA shouldn't see a disabled global taxonomy"
+ ),
+ (
+ "course_staffA",
+ "tA2",
+ "User with CourseStaffRole in a course from orgA shouldn't see a disabled taxonomy from orgA"
+ ),
+ (
+ "course_staffA",
+ "tBA2",
+ "User with CourseStaffRole in a course from orgA shouldn't see a disabled taxonomy from orgA"
+ ),
+ (
+ "course_staffA",
+ "t2",
+ "User with CourseStaffRole in a course from orgA should't see a disabled global taxonomy"
+ ),
+ (
+ "library_userA",
+ "tA2",
+ "User with permission on a library from orgA shouldn't see an disabled taxonomy from orgA"
+ ),
+ (
+ "library_userA",
+ "tBA2",
+ "User with permission on a library from orgA shouldn't see an disabled taxonomy from orgA"
+ ),
+ (
+ "library_userA",
+ "t2",
+ "User with permission on a library from orgA shouldn't see an disabled global taxonomy"
+ ),
+ )
+ @ddt.unpack
+ def test_detail_taxonomy_org_user_dont_see_disabled(self, user_attr: str, taxonomy_attr: str, reason: str) -> None:
+ """
+ Tests that org users (content creators and instructors) can't see disabled global taxonomies and taxonomies
+ from their orgs
+ """
+ self._test_api_call(
+ user_attr=user_attr,
+ taxonomy_attr=taxonomy_attr,
+ expected_status=status.HTTP_404_NOT_FOUND,
+ reason=reason,
+ )
+
+ @ddt.data(
+ ("staff", "ot1", "Staff should see an enabled no org taxonomy"),
+ ("staff", "ot2", "Staff should see a disabled no org taxonomy"),
+ )
+ @ddt.unpack
+ def test_detail_taxonomy_staff_see_no_org(self, user_attr: str, taxonomy_attr: str, reason: str) -> None:
+ """
+ Tests that staff can see taxonomies with no org
+ """
+ self._test_api_call(
+ user_attr=user_attr,
+ taxonomy_attr=taxonomy_attr,
+ expected_status=status.HTTP_200_OK,
+ reason=reason,
+ )
+
+ @ddt.data(
+ "staffA",
+ "content_creatorA",
+ "instructorA",
+ "library_staffA",
+ "course_instructorA",
+ "course_staffA",
+ "library_userA"
+ )
+ def test_detail_taxonomy_other_dont_see_no_org(self, user_attr: str) -> None:
+ """
+ Tests that org users can't see taxonomies with no org
+ """
+ self._test_api_call(
+ user_attr=user_attr,
+ taxonomy_attr="ot1",
+ expected_status=status.HTTP_404_NOT_FOUND,
+ reason="Only staff should see taxonomies with no org",
+ )
+
+ @ddt.data(
+ "staffA",
+ "content_creatorA",
+ "instructorA",
+ "library_staffA",
+ "course_instructorA",
+ "course_staffA",
+ "library_userA"
+ )
+ def test_detail_taxonomy_dont_see_other_org(self, user_attr: str) -> None:
+ """
+ Tests that org users can't see taxonomies from other orgs
+ """
+ self._test_api_call(
+ user_attr=user_attr,
+ taxonomy_attr="tB1",
+ expected_status=status.HTTP_404_NOT_FOUND,
+ reason="Users shouldn't see taxonomies from other orgs",
+ )
+
+ @ddt.data(
+ "ot1",
+ "ot2",
+ "st1",
+ "st2",
+ "t1",
+ "t2",
+ "tA1",
+ "tA2",
+ "tB1",
+ "tB2",
+ "tBA1",
+ "tBA2",
+ )
+ def test_detail_taxonomy_staff_see_all(self, taxonomy_attr: str) -> None:
+ """
+ Tests that staff can see all taxonomies
+ """
+ self._test_api_call(
+ user_attr="staff",
+ taxonomy_attr=taxonomy_attr,
+ expected_status=status.HTTP_200_OK,
+ reason="Staff should see all taxonomies",
+ )
+
+
+@skip_unless_cms
+class TestTaxonomyDetailViewSet(TestTaxonomyDetailExportMixin, APITestCase):
+ """
+ Test cases for TaxonomyViewSet with detail action
+ """
+
+ def _test_api_call(self, **kwargs) -> None:
+ """
+ Helper function to call the retrieve endpoint and check the response
+ """
+ user_attr = kwargs.get("user_attr")
+ taxonomy_attr = kwargs.get("taxonomy_attr")
+ expected_status = kwargs.get("expected_status")
+ reason = kwargs.get("reason", "Unexpected response status")
+
+ assert taxonomy_attr is not None, "taxonomy_attr is required"
+ assert user_attr is not None, "user_attr is required"
+ assert expected_status is not None, "expected_status is required"
+
taxonomy = getattr(self, taxonomy_attr)
url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk)
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
+
+ response = self.client.get(url)
+ assert response.status_code == expected_status, reason
+
+ if status.is_success(expected_status):
+ check_taxonomy(response.data, taxonomy.pk, **(TaxonomySerializer(taxonomy.cast()).data))
+
+
+@skip_unless_cms
+class TestTaxonomyExportViewSet(TestTaxonomyDetailExportMixin, APITestCase):
+ """
+ Test cases for TaxonomyViewSet with export action
+ """
+
+ def _test_api_call(self, **kwargs) -> None:
+ """
+ Helper function to call the export endpoint and check the response
+ """
+ user_attr = kwargs.get("user_attr")
+ taxonomy_attr = kwargs.get("taxonomy_attr")
+ expected_status = kwargs.get("expected_status")
+ reason = kwargs.get("reason", "Unexpected response status")
+
+ assert taxonomy_attr is not None, "taxonomy_attr is required"
+ assert user_attr is not None, "user_attr is required"
+ assert expected_status is not None, "expected_status is required"
+
+ taxonomy = getattr(self, taxonomy_attr)
+
+ url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk)
+
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
+
+ response = self.client.get(url)
+ assert response.status_code == expected_status, reason
+ assert len(response.data) > 0
+
+
+@ddt.ddt
+class TestTaxonomyChangeMixin(TestTaxonomyObjectsMixin):
+ """
+ Test cases to be used with update, patch and delete actions
+ """
+
+ @abc.abstractmethod
+ def _test_api_call(self, **_kwargs) -> None:
+ """
+ Helper function to call the update/patch/delete endpoint and check the response
+ """
+
+ @ddt.data(
+ "ot1",
+ "ot2",
+ "st1",
+ "st2",
+ "t1",
+ "t2",
+ "tA1",
+ "tA2",
+ "tB1",
+ "tB2",
+ "tBA1",
+ "tBA2",
+ )
+ def test_regular_user_cant_edit_taxonomies(self, taxonomy_attr: str) -> None:
+ """
+ Tests that regular users can't edit taxonomies
+ """
+ self._test_api_call(
+ user_attr="user",
+ taxonomy_attr=taxonomy_attr,
+ expected_status=[status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND],
+ reason="Regular users shouldn't be able to edit taxonomies",
+ )
+
+ @ddt.data(
+ "content_creatorA",
+ "instructorA",
+ "library_staffA",
+ "course_instructorA",
+ "course_staffA",
+ "library_userA",
+ )
+ def test_org_user_cant_edit_org_taxonomies(self, user_attr: str) -> None:
+ """
+ Tests that content creators and instructors from orgA can't edit taxonomies from orgA
+ """
+ self._test_api_call(
+ user_attr=user_attr,
+ taxonomy_attr="tA1",
+ expected_status=[status.HTTP_403_FORBIDDEN],
+ reason="Content creators and instructors shouldn't be able to edit taxonomies",
+ )
+
+ @ddt.data(
+ "tA1",
+ "tA2",
+ "tBA1",
+ "tBA2",
+ )
+ def test_org_staff_can_edit_org_taxonomies(self, taxonomy_attr: str) -> None:
+ """
+ Tests that org staff can edit taxonomies from their orgs
+ """
+ self._test_api_call(
+ user_attr="staffA",
+ taxonomy_attr=taxonomy_attr,
+ # Check both status: 200 for update and 204 for delete
+ expected_status=[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT],
+ reason="Org staff should be able to edit taxonomies from their orgs",
+ )
+
+ @ddt.data(
+ "tB1",
+ "tB2",
+ )
+ def test_org_staff_cant_edit_other_org_taxonomies(self, taxonomy_attr: str) -> None:
+ """
+ Tests that org staff can't edit taxonomies from other orgs
+ """
+ self._test_api_call(
+ user_attr="staffA",
+ taxonomy_attr=taxonomy_attr,
+ expected_status=[status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND],
+ reason="Org staff shouldn't be able to edit taxonomies from other orgs",
+ )
+
+ @ddt.data(
+ "ot1",
+ "ot2",
+ "t1",
+ "t2",
+ "tA1",
+ "tA2",
+ "tB1",
+ "tB2",
+ "tBA1",
+ "tBA2",
+
+ )
+ def test_staff_can_edit_almost_all_taxonomies(self, taxonomy_attr: str) -> None:
+ """
+ Tests that staff can edit all but system defined taxonomies
+ """
+ self._test_api_call(
+ user_attr="staff",
+ taxonomy_attr=taxonomy_attr,
+ # Check both status: 200 for update and 204 for delete
+ expected_status=[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT],
+ reason="Staff should be able to edit all but system defined taxonomies",
+ )
+
+ @ddt.data(
+ "st1",
+ "st2",
+ )
+ def test_staff_cant_edit_system_defined_taxonomies(self, taxonomy_attr: str) -> None:
+ """
+ Tests that staff can't edit system defined taxonomies
+ """
+ self._test_api_call(
+ user_attr="staff",
+ taxonomy_attr=taxonomy_attr,
+ # Check both status: 200 for update and 204 for delete
+ expected_status=[status.HTTP_403_FORBIDDEN],
+ reason="Staff shouldn't be able to edit system defined ",
+ )
+
+
+@skip_unless_cms
+class TestTaxonomyUpdateViewSet(TestTaxonomyChangeMixin, APITestCase):
+ """
+ Test cases for TaxonomyViewSet with PUT method
+ """
+
+ def _test_api_call(self, **kwargs) -> None:
+ user_attr = kwargs.get("user_attr")
+ taxonomy_attr = kwargs.get("taxonomy_attr")
+ expected_status = kwargs.get("expected_status")
+ reason = kwargs.get("reason", "Unexpected response status")
+
+ assert taxonomy_attr is not None, "taxonomy_attr is required"
+ assert user_attr is not None, "user_attr is required"
+ assert expected_status is not None, "expected_status is required"
+
+ taxonomy = getattr(self, taxonomy_attr)
+
+ url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk)
+
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
response = self.client.put(url, {"name": "new name"}, format="json")
- assert response.status_code == expected_status
+ assert response.status_code in expected_status, reason
# If we were able to update the taxonomy, check if the name changed
- if status.is_success(expected_status):
+ if status.is_success(response.status_code):
response = self.client.get(url)
check_taxonomy(
response.data,
@@ -444,94 +968,38 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase):
"name": "new name",
"description": taxonomy.description,
"enabled": taxonomy.enabled,
- "required": taxonomy.required,
},
)
- @ddt.data(
- (False, status.HTTP_403_FORBIDDEN),
- (True, status.HTTP_403_FORBIDDEN),
- )
- @ddt.unpack
- def test_update_taxonomy_system_defined(self, update_value, expected_status):
- """
- Test that we can't update system_defined field
- """
- url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.st1.pk)
- self.client.force_authenticate(user=self.userS)
- response = self.client.put(url, {"name": "new name", "system_defined": update_value}, format="json")
- assert response.status_code == expected_status
+@skip_unless_cms
+class TestTaxonomyPatchViewSet(TestTaxonomyChangeMixin, APITestCase):
+ """
+ Test cases for TaxonomyViewSet with PATCH method
+ """
- # Verify that system_defined has not changed
- response = self.client.get(url)
- assert response.data["system_defined"] is True
+ def _test_api_call(self, **kwargs) -> None:
+ user_attr = kwargs.get("user_attr")
+ taxonomy_attr = kwargs.get("taxonomy_attr")
+ expected_status = kwargs.get("expected_status")
+ reason = kwargs.get("reason", "Unexpected response status")
+
+ assert taxonomy_attr is not None, "taxonomy_attr is required"
+ assert user_attr is not None, "user_attr is required"
+ assert expected_status is not None, "expected_status is required"
- @ddt.data(
- (None, "ot1", status.HTTP_403_FORBIDDEN),
- (None, "ot2", status.HTTP_403_FORBIDDEN),
- (None, "st1", status.HTTP_403_FORBIDDEN),
- (None, "st2", status.HTTP_403_FORBIDDEN),
- (None, "t1", status.HTTP_403_FORBIDDEN),
- (None, "t2", status.HTTP_403_FORBIDDEN),
- (None, "tA1", status.HTTP_403_FORBIDDEN),
- (None, "tA2", status.HTTP_403_FORBIDDEN),
- (None, "tB1", status.HTTP_403_FORBIDDEN),
- (None, "tB2", status.HTTP_403_FORBIDDEN),
- (None, "tC1", status.HTTP_403_FORBIDDEN),
- (None, "tC2", status.HTTP_403_FORBIDDEN),
- ("user", "ot1", status.HTTP_403_FORBIDDEN),
- ("user", "ot2", status.HTTP_403_FORBIDDEN),
- ("user", "st1", status.HTTP_403_FORBIDDEN),
- ("user", "st2", status.HTTP_403_FORBIDDEN),
- ("user", "t1", status.HTTP_403_FORBIDDEN),
- ("user", "t2", status.HTTP_403_FORBIDDEN),
- ("user", "tA1", status.HTTP_403_FORBIDDEN),
- ("user", "tA2", status.HTTP_403_FORBIDDEN),
- ("user", "tB1", status.HTTP_403_FORBIDDEN),
- ("user", "tB2", status.HTTP_403_FORBIDDEN),
- ("user", "tC1", status.HTTP_403_FORBIDDEN),
- ("user", "tC2", status.HTTP_403_FORBIDDEN),
- ("userA", "ot1", status.HTTP_403_FORBIDDEN),
- ("userA", "ot2", status.HTTP_403_FORBIDDEN),
- ("userA", "st1", status.HTTP_403_FORBIDDEN),
- ("userA", "st2", status.HTTP_403_FORBIDDEN),
- ("userA", "t1", status.HTTP_403_FORBIDDEN),
- ("userA", "t2", status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", status.HTTP_403_FORBIDDEN),
- ("userA", "tA2", status.HTTP_403_FORBIDDEN),
- ("userA", "tB1", status.HTTP_403_FORBIDDEN),
- ("userA", "tB2", status.HTTP_403_FORBIDDEN),
- ("userA", "tC1", status.HTTP_403_FORBIDDEN),
- ("userA", "tC2", status.HTTP_403_FORBIDDEN),
- ("userS", "ot1", status.HTTP_200_OK),
- ("userS", "ot2", status.HTTP_200_OK),
- ("userS", "st1", status.HTTP_403_FORBIDDEN),
- ("userS", "st2", status.HTTP_403_FORBIDDEN),
- ("userS", "t1", status.HTTP_200_OK),
- ("userS", "t2", status.HTTP_200_OK),
- ("userS", "tA1", status.HTTP_200_OK),
- ("userS", "tA2", status.HTTP_200_OK),
- ("userS", "tB1", status.HTTP_200_OK),
- ("userS", "tB2", status.HTTP_200_OK),
- ("userS", "tC1", status.HTTP_200_OK),
- ("userS", "tC2", status.HTTP_200_OK),
- )
- @ddt.unpack
- def test_patch_taxonomy(self, user_attr, taxonomy_attr, expected_status):
taxonomy = getattr(self, taxonomy_attr)
url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk)
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
response = self.client.patch(url, {"name": "new name"}, format="json")
- assert response.status_code == expected_status
+ assert response.status_code in expected_status, reason
# If we were able to patch the taxonomy, check if the name changed
- if status.is_success(expected_status):
+ if status.is_success(response.status_code):
response = self.client.get(url)
check_taxonomy(
response.data,
@@ -540,127 +1008,56 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase):
"name": "new name",
"description": taxonomy.description,
"enabled": taxonomy.enabled,
- "required": taxonomy.required,
},
)
- @ddt.data(
- (False, status.HTTP_403_FORBIDDEN),
- (True, status.HTTP_403_FORBIDDEN),
- )
- @ddt.unpack
- def test_patch_taxonomy_system_defined(self, update_value, expected_status):
- """
- Test that we can't patch system_defined field
- """
- url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.st1.pk)
- self.client.force_authenticate(user=self.userS)
- response = self.client.patch(url, {"name": "new name", "system_defined": update_value}, format="json")
- assert response.status_code == expected_status
+@skip_unless_cms
+class TestTaxonomyDeleteViewSet(TestTaxonomyChangeMixin, APITestCase):
+ """
+ Test cases for TaxonomyViewSet with DELETE method
+ """
- # Verify that system_defined has not changed
- response = self.client.get(url)
- assert response.data["system_defined"] is True
+ def _test_api_call(self, **kwargs) -> None:
+ user_attr = kwargs.get("user_attr")
+ taxonomy_attr = kwargs.get("taxonomy_attr")
+ expected_status = kwargs.get("expected_status")
+ reason = kwargs.get("reason", "Unexpected response status")
+
+ assert taxonomy_attr is not None, "taxonomy_attr is required"
+ assert user_attr is not None, "user_attr is required"
+ assert expected_status is not None, "expected_status is required"
- @ddt.data(
- (None, "ot1", status.HTTP_403_FORBIDDEN),
- (None, "ot2", status.HTTP_403_FORBIDDEN),
- (None, "st1", status.HTTP_403_FORBIDDEN),
- (None, "st2", status.HTTP_403_FORBIDDEN),
- (None, "t1", status.HTTP_403_FORBIDDEN),
- (None, "t2", status.HTTP_403_FORBIDDEN),
- (None, "tA1", status.HTTP_403_FORBIDDEN),
- (None, "tA2", status.HTTP_403_FORBIDDEN),
- (None, "tB1", status.HTTP_403_FORBIDDEN),
- (None, "tB2", status.HTTP_403_FORBIDDEN),
- (None, "tC1", status.HTTP_403_FORBIDDEN),
- (None, "tC2", status.HTTP_403_FORBIDDEN),
- ("user", "ot1", status.HTTP_403_FORBIDDEN),
- ("user", "ot2", status.HTTP_403_FORBIDDEN),
- ("user", "st1", status.HTTP_403_FORBIDDEN),
- ("user", "st2", status.HTTP_403_FORBIDDEN),
- ("user", "t1", status.HTTP_403_FORBIDDEN),
- ("user", "t2", status.HTTP_403_FORBIDDEN),
- ("user", "tA1", status.HTTP_403_FORBIDDEN),
- ("user", "tA2", status.HTTP_403_FORBIDDEN),
- ("user", "tB1", status.HTTP_403_FORBIDDEN),
- ("user", "tB2", status.HTTP_403_FORBIDDEN),
- ("user", "tC1", status.HTTP_403_FORBIDDEN),
- ("user", "tC2", status.HTTP_403_FORBIDDEN),
- ("userA", "ot1", status.HTTP_403_FORBIDDEN),
- ("userA", "ot2", status.HTTP_403_FORBIDDEN),
- ("userA", "st1", status.HTTP_403_FORBIDDEN),
- ("userA", "st2", status.HTTP_403_FORBIDDEN),
- ("userA", "t1", status.HTTP_403_FORBIDDEN),
- ("userA", "t2", status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", status.HTTP_403_FORBIDDEN),
- ("userA", "tA2", status.HTTP_403_FORBIDDEN),
- ("userA", "tB1", status.HTTP_403_FORBIDDEN),
- ("userA", "tB2", status.HTTP_403_FORBIDDEN),
- ("userA", "tC1", status.HTTP_403_FORBIDDEN),
- ("userA", "tC2", status.HTTP_403_FORBIDDEN),
- ("userS", "ot1", status.HTTP_204_NO_CONTENT),
- ("userS", "ot2", status.HTTP_204_NO_CONTENT),
- ("userS", "st1", status.HTTP_403_FORBIDDEN),
- ("userS", "st2", status.HTTP_403_FORBIDDEN),
- ("userS", "t1", status.HTTP_204_NO_CONTENT),
- ("userS", "t2", status.HTTP_204_NO_CONTENT),
- ("userS", "tA1", status.HTTP_204_NO_CONTENT),
- ("userS", "tA2", status.HTTP_204_NO_CONTENT),
- ("userS", "tB1", status.HTTP_204_NO_CONTENT),
- ("userS", "tB2", status.HTTP_204_NO_CONTENT),
- ("userS", "tC1", status.HTTP_204_NO_CONTENT),
- ("userS", "tC2", status.HTTP_204_NO_CONTENT),
- )
- @ddt.unpack
- def test_delete_taxonomy(self, user_attr, taxonomy_attr, expected_status):
taxonomy = getattr(self, taxonomy_attr)
url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk)
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
response = self.client.delete(url)
- assert response.status_code == expected_status
+ assert response.status_code in expected_status, reason
# If we were able to delete the taxonomy, check that it's really gone
- if status.is_success(expected_status):
+ if status.is_success(response.status_code):
response = self.client.get(url)
assert response.status_code == status.HTTP_404_NOT_FOUND
-@skip_unless_cms
-@ddt.ddt
-@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": False})
-class TestTaxonomyViewSetNoCreatorGroup(TestTaxonomyViewSet): # pylint: disable=test-inherits-tests
+class TestObjectTagMixin(TestTaxonomyObjectsMixin):
"""
- Test cases for TaxonomyViewSet when ENABLE_CREATOR_GROUP is False
-
- The permissions are the same for when ENABLED_CREATOR_GRUP is True
- """
-
-
-@skip_unless_cms
-@ddt.ddt
-class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase):
- """
- Testing various cases for the ObjectTagView.
+ Sets up data for testing ObjectTags.
"""
def setUp(self):
"""
Setup the test cases
"""
super().setUp()
- self.courseA = CourseLocator("orgA", "101", "test")
self.xblockA = BlockUsageLocator(
course_key=self.courseA,
block_type='problem',
block_id='block_id'
)
- self.courseB = CourseLocator("orgB", "101", "test")
self.xblockB = BlockUsageLocator(
course_key=self.courseB,
block_type='problem',
@@ -668,13 +1065,13 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase):
)
self.multiple_taxonomy = Taxonomy.objects.create(name="Multiple Taxonomy", allow_multiple=True)
- self.required_taxonomy = Taxonomy.objects.create(name="Required Taxonomy", required=True)
+ self.single_value_taxonomy = Taxonomy.objects.create(name="Required Taxonomy", allow_multiple=False)
for i in range(20):
# Valid ObjectTags
Tag.objects.create(taxonomy=self.tA1, value=f"Tag {i}")
Tag.objects.create(taxonomy=self.tA2, value=f"Tag {i}")
Tag.objects.create(taxonomy=self.multiple_taxonomy, value=f"Tag {i}")
- Tag.objects.create(taxonomy=self.required_taxonomy, value=f"Tag {i}")
+ Tag.objects.create(taxonomy=self.single_value_taxonomy, value=f"Tag {i}")
self.open_taxonomy = Taxonomy.objects.create(name="Enabled Free-Text Taxonomy", allow_free_text=True)
@@ -685,7 +1082,7 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase):
rel_type=TaxonomyOrg.RelType.OWNER,
)
TaxonomyOrg.objects.create(
- taxonomy=self.required_taxonomy,
+ taxonomy=self.single_value_taxonomy,
org=self.orgA,
rel_type=TaxonomyOrg.RelType.OWNER,
)
@@ -695,37 +1092,41 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase):
rel_type=TaxonomyOrg.RelType.OWNER,
)
- add_users(self.userS, CourseStaffRole(self.courseA), self.userA)
+ add_users(self.staff, CourseStaffRole(self.courseA), self.staffA)
+
+
+@skip_unless_cms
+@ddt.ddt
+class TestObjectTagViewSet(TestObjectTagMixin, APITestCase):
+ """
+ Testing various cases for the ObjectTagView.
+ """
+
+ def test_get_tags(self):
+ pass
@ddt.data(
# userA and userS are staff in courseA and can tag using enabled taxonomies
- (None, "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN),
("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", ["Tag 1"], status.HTTP_200_OK),
- ("userS", "tA1", ["Tag 1"], status.HTTP_200_OK),
- (None, "tA1", [], status.HTTP_403_FORBIDDEN),
+ ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK),
+ ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK),
("user", "tA1", [], status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", [], status.HTTP_200_OK),
- ("userS", "tA1", [], status.HTTP_200_OK),
- (None, "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN),
+ ("staffA", "tA1", [], status.HTTP_200_OK),
+ ("staff", "tA1", [], status.HTTP_200_OK),
("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN),
- ("userA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
- ("userS", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
- (None, "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN),
+ ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
+ ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN),
- ("userA", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
- ("userS", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
- # Only userS is Tagging Admin and can tag objects using disabled taxonomies
- (None, "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("user", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("userA", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("userS", "tA2", ["Tag 1"], status.HTTP_200_OK),
+ ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
+ ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
)
@ddt.unpack
def test_tag_course(self, user_attr, taxonomy_attr, tag_values, expected_status):
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ """
+ Tests that only staff and org level users can tag courses
+ """
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
taxonomy = getattr(self, taxonomy_attr)
@@ -738,62 +1139,72 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase):
assert len(response.data) == len(tag_values)
assert set(t["value"] for t in response.data) == set(tag_values)
+ # Check that re-fetching the tags returns what we set
+ response = self.client.get(url, format="json")
+ assert status.is_success(response.status_code)
+ assert set(t["value"] for t in response.data) == set(tag_values)
+
@ddt.data(
- # Can't add invalid tags to a object using a closed taxonomy
- (None, "tA1", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("user", "tA1", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST),
- ("userS", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST),
- (None, "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("user", "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("userA", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST),
- ("userS", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST),
- # Staff can't add invalid tags to a object using a closed taxonomy
- ("userS", "tA2", ["invalid"], status.HTTP_400_BAD_REQUEST),
+ "staffA",
+ "staff",
+ )
+ def test_tag_course_disabled_taxonomy(self, user_attr):
+ """
+ Nobody can use disable taxonomies to tag objects
+ """
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
+
+ disabled_taxonomy = self.tA2
+ assert disabled_taxonomy.enabled is False
+
+ url = OBJECT_TAG_UPDATE_URL.format(object_id=self.courseA, taxonomy_id=disabled_taxonomy.pk)
+ response = self.client.put(url, {"tags": ["Tag 1"]}, format="json")
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ @ddt.data(
+ ("staffA", "tA1"),
+ ("staff", "tA1"),
+ ("staffA", "multiple_taxonomy"),
+ ("staff", "multiple_taxonomy"),
)
@ddt.unpack
- def test_tag_course_invalid(self, user_attr, taxonomy_attr, tag_values, expected_status):
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ def test_tag_course_invalid(self, user_attr, taxonomy_attr):
+ """
+ Tests that nobody can add invalid tags to a course using a closed taxonomy
+ """
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
taxonomy = getattr(self, taxonomy_attr)
url = OBJECT_TAG_UPDATE_URL.format(object_id=self.courseA, taxonomy_id=taxonomy.pk)
- response = self.client.put(url, {"tags": tag_values}, format="json")
- assert response.status_code == expected_status
- assert not status.is_success(expected_status) # No success cases here
+ response = self.client.put(url, {"tags": ["invalid"]}, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
@ddt.data(
- # userA and userS are staff in courseA (owner of xblockA) and can tag using enabled taxonomies
- (None, "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN),
+ # userA and userS are staff in courseA (owner of xblockA) and can tag using any taxonomies
("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", ["Tag 1"], status.HTTP_200_OK),
- ("userS", "tA1", ["Tag 1"], status.HTTP_200_OK),
- (None, "tA1", [], status.HTTP_403_FORBIDDEN),
- ("user", "tA1", [], status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", [], status.HTTP_200_OK),
- ("userS", "tA1", [], status.HTTP_200_OK),
- (None, "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN),
+ ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK),
+ ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK),
("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN),
- ("userA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
- ("userS", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
- (None, "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN),
+ ("staffA", "tA1", [], status.HTTP_200_OK),
+ ("staff", "tA1", [], status.HTTP_200_OK),
+ ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
+ ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK),
("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN),
- ("userA", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
- ("userS", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
- # Only userS is Tagging Admin and can tag objects using disabled taxonomies
- (None, "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("user", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("userA", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN),
- ("userS", "tA2", ["Tag 1"], status.HTTP_200_OK),
+ ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
+ ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK),
)
@ddt.unpack
def test_tag_xblock(self, user_attr, taxonomy_attr, tag_values, expected_status):
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ """
+ Tests that only staff and org level users can tag xblocks
+ """
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
taxonomy = getattr(self, taxonomy_attr)
@@ -806,32 +1217,67 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase):
assert len(response.data) == len(tag_values)
assert set(t["value"] for t in response.data) == set(tag_values)
+ # Check that re-fetching the tags returns what we set
+ response = self.client.get(url, format="json")
+ assert status.is_success(response.status_code)
+ assert set(t["value"] for t in response.data) == set(tag_values)
+
@ddt.data(
- # Can't add invalid tags to a object using a closed taxonomy
- (None, "tA1", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("user", "tA1", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("userA", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST),
- ("userS", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST),
- (None, "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("user", "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN),
- ("userA", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST),
- ("userS", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST),
- # Staff can't add invalid tags to a object using a closed taxonomy
- ("userS", "tA2", ["invalid"], status.HTTP_400_BAD_REQUEST),
+ "staffA",
+ "staff",
+ )
+ def test_tag_xblock_disabled_taxonomy(self, user_attr):
+ """
+ Tests that nobody can use disabled taxonomies to tag xblocks
+ """
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
+
+ disabled_taxonomy = self.tA2
+ assert disabled_taxonomy.enabled is False
+
+ url = OBJECT_TAG_UPDATE_URL.format(object_id=self.xblockA, taxonomy_id=disabled_taxonomy.pk)
+ response = self.client.put(url, {"tags": ["Tag 1"]}, format="json")
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
+
+ @ddt.data(
+ ("staffA", "tA1"),
+ ("staff", "tA1"),
+ ("staffA", "multiple_taxonomy"),
+ ("staff", "multiple_taxonomy"),
)
@ddt.unpack
- def test_tag_xblock_invalid(self, user_attr, taxonomy_attr, tag_values, expected_status):
- if user_attr:
- user = getattr(self, user_attr)
- self.client.force_authenticate(user=user)
+ def test_tag_xblock_invalid(self, user_attr, taxonomy_attr):
+ """
+ Tests that staff can't add invalid tags to a xblock using a closed taxonomy
+ """
+ user = getattr(self, user_attr)
+ self.client.force_authenticate(user=user)
taxonomy = getattr(self, taxonomy_attr)
url = OBJECT_TAG_UPDATE_URL.format(object_id=self.xblockA, taxonomy_id=taxonomy.pk)
- response = self.client.put(url, {"tags": tag_values}, format="json")
- assert response.status_code == expected_status
- assert not status.is_success(expected_status) # No success cases here
+ response = self.client.put(url, {"tags": ["invalid"]}, format="json")
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
+ @ddt.data(
+ "courseB",
+ "xblockB",
+ )
+ def test_tag_no_permission(self, objectid_attr):
+ """
+ Test that a user without access to courseB can't apply tags to it
+ """
+ self.client.force_authenticate(user=self.staffA)
+ object_id = getattr(self, objectid_attr)
+
+ url = OBJECT_TAG_UPDATE_URL.format(object_id=object_id, taxonomy_id=self.tA1.pk)
+
+ response = self.client.put(url, {"tags": ["Tag 1"]}, format="json")
+
+ assert response.status_code == status.HTTP_403_FORBIDDEN
@ddt.data(
"courseB",
@@ -841,11 +1287,40 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase):
"""
Test that a user without access to courseB can't apply tags to it
"""
- self.client.force_authenticate(user=self.userA)
object_id = getattr(self, objectid_attr)
url = OBJECT_TAG_UPDATE_URL.format(object_id=object_id, taxonomy_id=self.tA1.pk)
response = self.client.put(url, {"tags": ["Tag 1"]}, format="json")
- assert response.status_code == status.HTTP_403_FORBIDDEN
+ assert response.status_code == status.HTTP_401_UNAUTHORIZED
+
+
+@skip_unless_cms
+@ddt.ddt
+class TestDownloadTemplateView(APITestCase):
+ """
+ Tests the taxonomy template downloads.
+ """
+ @ddt.data(
+ ("template.csv", "text/csv"),
+ ("template.json", "application/json"),
+ )
+ @ddt.unpack
+ def test_download(self, filename, content_type):
+ url = TAXONOMY_TEMPLATE_URL.format(filename=filename)
+ response = self.client.get(url)
+ assert response.status_code == status.HTTP_200_OK
+ assert response.headers['Content-Type'] == content_type
+ assert response.headers['Content-Disposition'] == f'attachment; filename="{filename}"'
+ assert int(response.headers['Content-Length']) > 0
+
+ def test_download_not_found(self):
+ url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt")
+ response = self.client.get(url)
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_download_method_not_allowed(self):
+ url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt")
+ response = self.client.post(url)
+ assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py
index 5c0bceb38e..38bb0a9ac1 100644
--- a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py
+++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py
@@ -6,14 +6,21 @@ from rest_framework.routers import DefaultRouter
from django.urls.conf import path, include
-from openedx_tagging.core.tagging.rest_api.v1 import views as oel_tagging_views
+from openedx_tagging.core.tagging.rest_api.v1 import (
+ views_import as oel_tagging_views_import,
+)
from . import views
router = DefaultRouter()
router.register("taxonomies", views.TaxonomyOrgView, basename="taxonomy")
-router.register("object_tags", oel_tagging_views.ObjectTagView, basename="object_tag")
+router.register("object_tags", views.ObjectTagOrgView, basename="object_tag")
urlpatterns = [
+ path(
+ "taxonomies/import/template.",
+ oel_tagging_views_import.TemplateView.as_view(),
+ name="taxonomy-import-template",
+ ),
path('', include(router.urls))
]
diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py
index 7bdfe7cd39..6f1f7dd73a 100644
--- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py
+++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py
@@ -2,7 +2,7 @@
Tagging Org API Views
"""
-from openedx_tagging.core.tagging.rest_api.v1.views import TaxonomyView
+from openedx_tagging.core.tagging.rest_api.v1.views import ObjectTagView, TaxonomyView
from ...api import (
@@ -10,8 +10,9 @@ from ...api import (
get_taxonomies,
get_taxonomies_for_org,
)
+from ...rules import get_admin_orgs
from .serializers import TaxonomyOrgListQueryParamsSerializer
-from .filters import UserOrgFilterBackend
+from .filters import ObjectTagTaxonomyOrgFilterBackend, UserOrgFilterBackend
class TaxonomyOrgView(TaxonomyView):
@@ -57,4 +58,15 @@ class TaxonomyOrgView(TaxonomyView):
"""
Create a new taxonomy.
"""
- serializer.instance = create_taxonomy(**serializer.validated_data)
+ user_admin_orgs = get_admin_orgs(self.request.user)
+ serializer.instance = create_taxonomy(**serializer.validated_data, orgs=user_admin_orgs)
+
+
+class ObjectTagOrgView(ObjectTagView):
+ """
+ View to create and retrieve ObjectTags for a provided Object ID (object_id).
+ This view extends the ObjectTagView to add Organization filters for the results.
+
+ Refer to ObjectTagView docstring for usage details.
+ """
+ filter_backends = [ObjectTagTaxonomyOrgFilterBackend]
diff --git a/openedx/core/djangoapps/content_tagging/rules.py b/openedx/core/djangoapps/content_tagging/rules.py
index bad38019ce..5206f62045 100644
--- a/openedx/core/djangoapps/content_tagging/rules.py
+++ b/openedx/core/djangoapps/content_tagging/rules.py
@@ -6,35 +6,210 @@ from typing import Union
import django.contrib.auth.models
import openedx_tagging.core.tagging.rules as oel_tagging
+from organizations.models import Organization
import rules
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
-from common.djangoapps.student.auth import is_content_creator, has_studio_write_access
+from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
+from common.djangoapps.student.models import CourseAccessRole
+from common.djangoapps.student.roles import (
+ CourseInstructorRole,
+ CourseStaffRole,
+ OrgContentCreatorRole,
+ OrgInstructorRole,
+ OrgLibraryUserRole,
+ OrgStaffRole
+)
+from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user
from .models import TaxonomyOrg
UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser]
-def is_taxonomy_user(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool:
+def is_org_admin(user: UserType, orgs: list[Organization] | None = None) -> bool:
+ """
+ Return True if the given user is an admin for any of the given orgs.
"""
- Returns True if the given user is a Taxonomy User for the given content taxonomy.
- Taxonomy users include global staff and superusers, plus course creators who can create courses for any org.
- Otherwise, we need to check taxonomy provided to determine if the user is an org-level course creator for one of
- the orgs allowed to use this taxonomy. Only global staff and superusers can use disabled system taxonomies.
+ return len(get_admin_orgs(user, orgs)) > 0
+
+
+def is_org_user(user: UserType, orgs: list[Organization]) -> bool:
"""
+ Return True if the given user is a member of any of the given orgs.
+ """
+ return len(get_user_orgs(user, orgs)) > 0
+
+
+def get_admin_orgs(user: UserType, orgs: list[Organization] | None = None) -> list[Organization]:
+ """
+ Returns a list of orgs that the given user is an admin, from the given list of orgs.
+
+ If no orgs are provided, check all orgs
+ """
+ org_list = Organization.objects.all() if orgs is None else orgs
+ return [
+ org for org in org_list if OrgStaffRole(org=org.short_name).has_user(user)
+ ]
+
+
+def get_content_creator_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
+ """
+ Returns a list of orgs that the given user is a content creator, the given list of orgs.
+ """
+ return [
+ org for org in orgs if (
+ OrgLibraryUserRole(org=org.short_name).has_user(user) or
+ OrgInstructorRole(org=org.short_name).has_user(user) or
+ OrgContentCreatorRole(org=org.short_name).has_user(user)
+ )
+ ]
+
+
+def get_instructor_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
+ """
+ Returns a list of orgs that the given user is an instructor, from the given list of orgs.
+ """
+ instructor_roles = CourseAccessRole.objects.filter(
+ org__in=(org.short_name for org in orgs),
+ user=user,
+ role__in=(CourseStaffRole.ROLE, CourseInstructorRole.ROLE),
+ )
+ instructor_orgs = [role.org for role in instructor_roles]
+ return [org for org in orgs if org.short_name in instructor_orgs]
+
+
+def get_library_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
+ """
+ Returns a list of orgs that the given user has explicity permission, from the given list of orgs.
+ """
+ return [
+ org for org in orgs if (
+ len(get_libraries_for_user(user, org=org.short_name)) > 0
+ )
+ ]
+
+
+def get_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]:
+ """
+ Return a list of orgs that the given user is a member of (instructor or content creator),
+ from the given list of orgs.
+ """
+ content_creator_orgs = get_content_creator_orgs(user, orgs)
+ instructor_orgs = get_instructor_orgs(user, orgs)
+ library_user_orgs = get_library_user_orgs(user, orgs)
+ user_orgs = list(set(content_creator_orgs) | set(instructor_orgs) | set(library_user_orgs))
+
+ return user_orgs
+
+
+def can_create_taxonomy(user: UserType) -> bool:
+ """
+ Returns True if the given user can create a taxonomy.
+
+ Taxonomy admins and org-level staff can create taxonomies.
+ """
+ # Taxonomy admins can view any taxonomy
if oel_tagging.is_taxonomy_admin(user):
return True
+ # Org-level staff can create taxonomies associated with one of their orgs.
+ if is_org_admin(user):
+ return True
+
+ return False
+
+
+@rules.predicate
+def can_view_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool:
+ """
+ Returns True if the given user can view the given taxonomy.
+
+ Taxonomy admins can view any taxonomy.
+ Org-level staff can view any taxonomy that is associated with one of their orgs.
+ Org-level course creators and instructors can view any enabled taxonomy that is owned by one of their orgs.
+ """
+ # The following code allows METHOD permission (GET) in the viewset for everyone
+ if not taxonomy:
+ return True
+
+ taxonomy = taxonomy.cast()
+
+ # Taxonomy admins can view any taxonomy
+ if oel_tagging.is_taxonomy_admin(user):
+ return True
+
+ is_all_org = TaxonomyOrg.objects.filter(
+ taxonomy=taxonomy,
+ org=None,
+ rel_type=TaxonomyOrg.RelType.OWNER,
+ ).exists()
+
+ # Enabled all-org taxonomies can be viewed by any registred user
+ if is_all_org:
+ return taxonomy.enabled
+
taxonomy_orgs = TaxonomyOrg.get_organizations(
taxonomy=taxonomy,
rel_type=TaxonomyOrg.RelType.OWNER,
)
- for org in taxonomy_orgs:
- if is_content_creator(user, org.short_name):
- return True
+
+ # Org-level staff can view any taxonomy that is associated with one of their orgs.
+ if is_org_admin(user, taxonomy_orgs):
+ return True
+
+ # Org-level course creators and instructors can view any enabled taxonomy that is owned by one of their orgs.
+ if is_org_user(user, taxonomy_orgs):
+ return taxonomy.enabled
+
+ return False
+
+
+@rules.predicate
+def can_change_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool:
+ """
+ Returns True if the given user can edit the given taxonomy.
+
+ System definied taxonomies cannot be edited
+ Taxonomy admins can edit any non system defined taxonomies
+ Only taxonomy admins can edit all org taxonomies
+ Org-level staff can edit any taxonomy that is associated with one of their orgs.
+ """
+ # The following code allows METHOD permission (PUT, PATCH) in the viewset for everyone
+ if not taxonomy:
+ return True
+
+ taxonomy = taxonomy.cast()
+
+ # System definied taxonomies cannot be edited
+ if taxonomy.system_defined:
+ return False
+
+ # Taxonomy admins can edit any non system defined taxonomies
+ if oel_tagging.is_taxonomy_admin(user):
+ return True
+
+ is_all_org = TaxonomyOrg.objects.filter(
+ taxonomy=taxonomy,
+ org=None,
+ rel_type=TaxonomyOrg.RelType.OWNER,
+ ).exists()
+
+ # Only taxonomy admins can edit all org taxonomies
+ if is_all_org:
+ return False
+
+ taxonomy_orgs = TaxonomyOrg.get_organizations(
+ taxonomy=taxonomy,
+ rel_type=TaxonomyOrg.RelType.OWNER,
+ )
+
+ # Org-level staff can edit any taxonomy that is associated with one of their orgs.
+ if is_org_admin(user, taxonomy_orgs):
+ return True
+
return False
@@ -57,12 +232,32 @@ def can_change_object_tag_objectid(user: UserType, object_id: str) -> bool:
@rules.predicate
-def can_change_object_tag_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool:
+def can_view_object_tag_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool:
"""
- Taxonomy users can tag objects using tags from any taxonomy that they have permission to view. Only taxonomy admins
- can tag objects using tags from disabled taxonomies.
+ Only enabled taxonomy and users with permission to view this taxonomy can view object tags
+ from that taxonomy.
+
+ This rule is different from can_view_taxonomy because it checks if the taxonomy is enabled.
"""
- return oel_tagging.is_taxonomy_admin(user) or (taxonomy.cast().enabled and is_taxonomy_user(user, taxonomy))
+ return taxonomy.cast().enabled and can_view_taxonomy(user, taxonomy)
+
+
+@rules.predicate
+def can_view_object_tag_objectid(user: UserType, object_id: str) -> bool:
+ """
+ Everyone that has permission to view the object should be able to tag it.
+ """
+ if not object_id:
+ raise ValueError("object_id must be provided")
+ try:
+ usage_key = UsageKey.from_string(object_id)
+ if not usage_key.course_key.is_course:
+ raise ValueError("object_id must be from a block or a course")
+ course_key = usage_key.course_key
+ except InvalidKeyError:
+ course_key = CourseKey.from_string(object_id)
+
+ return has_studio_read_access(user, course_key)
@rules.predicate
@@ -82,10 +277,11 @@ def can_change_taxonomy_tag(user: UserType, tag: oel_tagging.Tag | None = None)
# Taxonomy
-rules.set_perm("oel_tagging.add_taxonomy", oel_tagging.is_taxonomy_admin)
-rules.set_perm("oel_tagging.change_taxonomy", oel_tagging.can_change_taxonomy)
-rules.set_perm("oel_tagging.delete_taxonomy", oel_tagging.can_change_taxonomy)
-rules.set_perm("oel_tagging.view_taxonomy", oel_tagging.can_view_taxonomy)
+rules.set_perm("oel_tagging.add_taxonomy", can_create_taxonomy)
+rules.set_perm("oel_tagging.change_taxonomy", can_change_taxonomy)
+rules.set_perm("oel_tagging.delete_taxonomy", can_change_taxonomy)
+rules.set_perm("oel_tagging.view_taxonomy", can_view_taxonomy)
+rules.set_perm("oel_tagging.export_taxonomy", can_view_taxonomy)
# Tag
rules.set_perm("oel_tagging.add_tag", can_change_taxonomy_tag)
@@ -95,11 +291,13 @@ rules.set_perm("oel_tagging.view_tag", rules.always_allow)
# ObjectTag
rules.set_perm("oel_tagging.add_object_tag", oel_tagging.can_change_object_tag)
-rules.set_perm("oel_tagging.change_object_tag", oel_tagging.can_change_object_tag)
-rules.set_perm("oel_tagging.delete_object_tag", oel_tagging.can_change_object_tag)
-rules.set_perm("oel_tagging.view_object_tag", rules.always_allow)
+rules.set_perm("oel_tagging.change_objecttag", oel_tagging.can_change_object_tag)
+rules.set_perm("oel_tagging.delete_objecttag", oel_tagging.can_change_object_tag)
+rules.set_perm("oel_tagging.view_objecttag", oel_tagging.can_view_object_tag)
# This perms are used in the tagging rest api from openedx_tagging that is exposed in the CMS. They are overridden here
# to include Organization and objects permissions.
-rules.set_perm("oel_tagging.change_objecttag_taxonomy", can_change_object_tag_taxonomy)
+rules.set_perm("oel_tagging.view_objecttag_taxonomy", can_view_object_tag_taxonomy)
+rules.set_perm("oel_tagging.view_objecttag_objectid", can_view_object_tag_objectid)
+rules.set_perm("oel_tagging.change_objecttag_taxonomy", can_view_object_tag_taxonomy)
rules.set_perm("oel_tagging.change_objecttag_objectid", can_change_object_tag_objectid)
diff --git a/openedx/core/djangoapps/content_tagging/tasks.py b/openedx/core/djangoapps/content_tagging/tasks.py
index 3ffa6c29f1..063a24072e 100644
--- a/openedx/core/djangoapps/content_tagging/tasks.py
+++ b/openedx/core/djangoapps/content_tagging/tasks.py
@@ -10,12 +10,13 @@ from celery_utils.logged_task import LoggedTask
from django.conf import settings
from django.contrib.auth import get_user_model
from edx_django_utils.monitoring import set_code_owner_attribute
-from opaque_keys.edx.keys import CourseKey, UsageKey
+from opaque_keys.edx.keys import LearningContextKey, UsageKey
from openedx_tagging.core.tagging.models import Taxonomy
from xmodule.modulestore.django import modulestore
from . import api
+from .types import ContentKey
LANGUAGE_TAXONOMY_ID = -1
@@ -23,53 +24,32 @@ log = logging.getLogger(__name__)
User = get_user_model()
-def _has_taxonomy(taxonomy: Taxonomy, content_object: CourseKey | UsageKey) -> bool:
- """
- Return True if this Taxonomy have some Tag set in the content_object
- """
- _exausted = object()
-
- content_tags = api.get_content_tags(object_id=str(content_object), taxonomy_id=taxonomy.id)
- return next(content_tags, _exausted) is not _exausted
-
-
-def _set_initial_language_tag(content_object: CourseKey | UsageKey, lang: str) -> None:
+def _set_initial_language_tag(content_key: ContentKey, lang_code: str) -> None:
"""
Create a tag for the language taxonomy in the content_object if it doesn't exist.
+ lang_code is the two-letter language code, optionally with country suffix.
+
If the language is not configured in the plataform or the language tag doesn't exist,
use the default language of the platform.
"""
- lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID)
+ lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID).cast()
- if lang and not _has_taxonomy(lang_taxonomy, content_object):
- tags = api.get_tags(lang_taxonomy)
- is_language_configured = any(lang_code == lang for lang_code, _ in settings.LANGUAGES) is not None
- if not is_language_configured:
+ if lang_code and not api.get_content_tags(object_key=content_key, taxonomy_id=lang_taxonomy.id):
+ try:
+ lang_tag = lang_taxonomy.tag_for_external_id(lang_code)
+ except api.oel_tagging.TagDoesNotExist:
+ default_lang_code = settings.LANGUAGE_CODE
logging.warning(
"Language not configured in the plataform: %s. Using default language: %s",
- lang,
- settings.LANGUAGE_CODE,
+ lang_code,
+ default_lang_code,
)
- lang = settings.LANGUAGE_CODE
-
- lang_tag = next((tag for tag in tags if tag.external_id == lang), None)
- if lang_tag is None:
- if not is_language_configured:
- logging.error(
- "Language tag not found for default language: %s. Skipping", lang
- )
- return
-
- logging.warning(
- "Language tag not found for language: %s. Using default language: %s", lang, settings.LANGUAGE_CODE
- )
- lang_tag = next(tag for tag in tags if tag.external_id == settings.LANGUAGE_CODE)
-
- api.tag_content_object(lang_taxonomy, [lang_tag.id], content_object)
+ lang_tag = lang_taxonomy.tag_for_external_id(default_lang_code)
+ api.tag_content_object(content_key, lang_taxonomy, [lang_tag.value])
-def _delete_tags(content_object: CourseKey | UsageKey) -> None:
+def _delete_tags(content_object: ContentKey) -> None:
api.delete_object_tags(str(content_object))
@@ -84,14 +64,14 @@ def update_course_tags(course_key_str: str) -> bool:
course_key_str (str): identifier of the Course
"""
try:
- course_key = CourseKey.from_string(course_key_str)
+ course_key = LearningContextKey.from_string(course_key_str)
log.info("Updating tags for Course with id: %s", course_key)
course = modulestore().get_course(course_key)
if course:
- lang = course.language
- _set_initial_language_tag(course_key, lang)
+ lang_code = course.language
+ _set_initial_language_tag(course_key, lang_code)
return True
except Exception as e: # pylint: disable=broad-except
@@ -109,7 +89,7 @@ def delete_course_tags(course_key_str: str) -> bool:
course_key_str (str): identifier of the Course
"""
try:
- course_key = CourseKey.from_string(course_key_str)
+ course_key = LearningContextKey.from_string(course_key_str)
log.info("Deleting tags for Course with id: %s", course_key)
@@ -140,11 +120,11 @@ def update_xblock_tags(usage_key_str: str) -> bool:
course = modulestore().get_course(usage_key.course_key)
if course is None:
return True
- lang = course.language
+ lang_code = course.language
else:
return True
- _set_initial_language_tag(usage_key, lang)
+ _set_initial_language_tag(usage_key, lang_code)
return True
except Exception as e: # pylint: disable=broad-except
diff --git a/openedx/core/djangoapps/content_tagging/tests/test_api.py b/openedx/core/djangoapps/content_tagging/tests/test_api.py
index 263ae761ef..985199322d 100644
--- a/openedx/core/djangoapps/content_tagging/tests/test_api.py
+++ b/openedx/core/djangoapps/content_tagging/tests/test_api.py
@@ -2,7 +2,7 @@
import ddt
from django.test.testcases import TestCase
from opaque_keys.edx.keys import CourseKey, UsageKey
-from openedx_tagging.core.tagging.models import ObjectTag, Tag
+from openedx_tagging.core.tagging.models import Tag
from organizations.models import Organization
from .. import api
@@ -22,7 +22,7 @@ class TestTaxonomyMixin:
name="Learning Objectives",
enabled=False,
)
- api.set_taxonomy_orgs(self.taxonomy_disabled, all_orgs=True)
+ api.set_taxonomy_orgs(self.taxonomy_disabled, orgs=[self.org1, self.org2])
self.taxonomy_all_orgs = api.create_taxonomy(
name="Content Types",
enabled=True,
@@ -65,59 +65,42 @@ class TestTaxonomyMixin:
)
# ObjectTags
self.all_orgs_course_tag = api.tag_content_object(
+ object_key=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"),
taxonomy=self.taxonomy_all_orgs,
- tags=[self.tag_all_orgs.id],
- object_id=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"),
+ tags=[self.tag_all_orgs.value],
)[0]
self.all_orgs_block_tag = api.tag_content_object(
- taxonomy=self.taxonomy_all_orgs,
- tags=[self.tag_all_orgs.id],
- object_id=UsageKey.from_string(
+ object_key=UsageKey.from_string(
"block-v1:Ax+DemoX+Demo_Course+type@vertical+block@abcde"
),
+ taxonomy=self.taxonomy_all_orgs,
+ tags=[self.tag_all_orgs.value],
)[0]
self.both_orgs_course_tag = api.tag_content_object(
+ object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"),
taxonomy=self.taxonomy_both_orgs,
- tags=[self.tag_both_orgs.id],
- object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"),
+ tags=[self.tag_both_orgs.value],
)[0]
self.both_orgs_block_tag = api.tag_content_object(
- taxonomy=self.taxonomy_both_orgs,
- tags=[self.tag_both_orgs.id],
- object_id=UsageKey.from_string(
+ object_key=UsageKey.from_string(
"block-v1:OeX+DemoX+Demo_Course+type@video+block@abcde"
),
+ taxonomy=self.taxonomy_both_orgs,
+ tags=[self.tag_both_orgs.value],
)[0]
self.one_org_block_tag = api.tag_content_object(
- taxonomy=self.taxonomy_one_org,
- tags=[self.tag_one_org.id],
- object_id=UsageKey.from_string(
+ object_key=UsageKey.from_string(
"block-v1:OeX+DemoX+Demo_Course+type@html+block@abcde"
),
+ taxonomy=self.taxonomy_one_org,
+ tags=[self.tag_one_org.value],
)[0]
self.disabled_course_tag = api.tag_content_object(
+ object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"),
taxonomy=self.taxonomy_disabled,
- tags=[self.tag_disabled.id],
- object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"),
+ tags=[self.tag_disabled.value],
)[0]
- # Invalid object tags must be manually created
- self.all_orgs_invalid_tag = ObjectTag.objects.create(
- taxonomy=self.taxonomy_all_orgs,
- tag=self.tag_all_orgs,
- object_id="course-v1_OpenedX_DemoX_Demo_Course",
- )
- self.one_org_invalid_org_tag = ObjectTag.objects.create(
- taxonomy=self.taxonomy_one_org,
- tag=self.tag_one_org,
- object_id="block-v1_OeX_DemoX_Demo_Course_type_html_block@abcde",
- )
- self.no_orgs_invalid_tag = ObjectTag.objects.create(
- taxonomy=self.taxonomy_no_orgs,
- tag=self.tag_no_orgs,
- object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"),
- )
-
@ddt.ddt
class TestAPITaxonomy(TestTaxonomyMixin, TestCase):
@@ -138,8 +121,8 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase):
@ddt.data(
# All orgs
(None, True, ["taxonomy_all_orgs"]),
- (None, False, ["taxonomy_disabled"]),
- (None, None, ["taxonomy_all_orgs", "taxonomy_disabled"]),
+ (None, False, []),
+ (None, None, ["taxonomy_all_orgs"]),
# Org 1
("org1", True, ["taxonomy_all_orgs", "taxonomy_one_org", "taxonomy_both_orgs"]),
("org1", False, ["taxonomy_disabled"]),
@@ -190,11 +173,11 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase):
):
taxonomy_id = getattr(self, taxonomy_attr).id
object_tag = getattr(self, object_tag_attr)
- with self.assertNumQueries(2):
+ with self.assertNumQueries(1):
valid_tags = list(
api.get_content_tags(
+ object_key=object_tag.object_key,
taxonomy_id=taxonomy_id,
- object_id=object_tag.object_id,
)
)
assert len(valid_tags) == 1
@@ -204,38 +187,55 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase):
("taxonomy_disabled", "disabled_course_tag"),
("taxonomy_all_orgs", "all_orgs_course_tag"),
("taxonomy_all_orgs", "all_orgs_block_tag"),
- ("taxonomy_all_orgs", "all_orgs_invalid_tag"),
("taxonomy_both_orgs", "both_orgs_course_tag"),
("taxonomy_both_orgs", "both_orgs_block_tag"),
("taxonomy_one_org", "one_org_block_tag"),
- ("taxonomy_one_org", "one_org_invalid_org_tag"),
)
@ddt.unpack
- def test_get_content_tags_include_invalid(
+ def test_get_content_tags(
self,
taxonomy_attr,
object_tag_attr,
):
taxonomy_id = getattr(self, taxonomy_attr).id
object_tag = getattr(self, object_tag_attr)
- with self.assertNumQueries(2):
+ with self.assertNumQueries(1):
valid_tags = list(
api.get_content_tags(
+ object_key=object_tag.object_key,
taxonomy_id=taxonomy_id,
- object_id=object_tag.object_id,
)
)
assert len(valid_tags) == 1
assert valid_tags[0].id == object_tag.id
- @ddt.data(
- "all_orgs_invalid_tag",
- "one_org_invalid_org_tag",
- "no_orgs_invalid_tag",
- )
- def test_object_tag_not_valid_check_object(self, tag_attr):
- object_tag = getattr(self, tag_attr)
- assert not object_tag.is_valid()
-
def test_get_tags(self):
assert api.get_tags(self.taxonomy_all_orgs) == [self.tag_all_orgs]
+
+ def test_cannot_tag_across_orgs(self):
+ """
+ Ensure that I cannot apply tags from a taxonomy that's linked to another
+ org.
+ """
+ # This taxonomy is only linked to the "OpenedX org", so it can't be used for "Axim" content.
+ taxonomy = self.taxonomy_one_org
+ tags = [self.tag_one_org.value]
+ with self.assertRaises(ValueError) as exc:
+ api.tag_content_object(
+ object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"),
+ taxonomy=taxonomy,
+ tags=tags,
+ )
+ assert "The specified Taxonomy is not enabled for the content object's org (Ax)" in str(exc.exception)
+ # But this will work fine:
+ api.tag_content_object(
+ object_key=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"),
+ taxonomy=taxonomy,
+ tags=tags,
+ )
+ # As will this:
+ api.tag_content_object(
+ object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"),
+ taxonomy=self.taxonomy_both_orgs,
+ tags=[self.tag_both_orgs.value],
+ )
diff --git a/openedx/core/djangoapps/content_tagging/tests/test_models.py b/openedx/core/djangoapps/content_tagging/tests/test_models.py
deleted file mode 100644
index 81c5da8641..0000000000
--- a/openedx/core/djangoapps/content_tagging/tests/test_models.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""
-Test for Content models
-"""
-import ddt
-from django.test.testcases import TestCase
-
-from openedx_tagging.core.tagging.models import (
- ObjectTag,
- Tag,
-)
-from openedx_tagging.core.tagging.api import create_taxonomy
-from ..models import (
- ContentLanguageTaxonomy,
- ContentAuthorTaxonomy,
-)
-
-
-@ddt.ddt
-class TestSystemDefinedModels(TestCase):
- """
- Test for System defined models
- """
-
- @ddt.data(
- (ContentLanguageTaxonomy, "taxonomy"), # Invalid object key
- (ContentLanguageTaxonomy, "tag"), # Invalid external_id, invalid language
- (ContentLanguageTaxonomy, "object"), # Invalid object key
- (ContentAuthorTaxonomy, "taxonomy"), # Invalid object key
- (ContentAuthorTaxonomy, "tag"), # Invalid external_id, User don't exits
- (ContentAuthorTaxonomy, "object"), # Invalid object key
- )
- @ddt.unpack
- def test_validations(
- self,
- taxonomy_cls,
- check,
- ):
- """
- Test that the respective validations are being called
- """
- taxonomy = create_taxonomy(
- name='Test taxonomy',
- taxonomy_class=taxonomy_cls,
- )
-
- tag = Tag(
- value="value",
- external_id="external_id",
- taxonomy=taxonomy,
- )
- tag.save()
-
- object_tag = ObjectTag(
- object_id='object_id',
- taxonomy=taxonomy,
- tag=tag,
- )
-
- check_taxonomy = check == 'taxonomy'
- check_object = check == 'object'
- check_tag = check == 'tag'
- assert not taxonomy.validate_object_tag(
- object_tag=object_tag,
- check_taxonomy=check_taxonomy,
- check_object=check_object,
- check_tag=check_tag,
- )
diff --git a/openedx/core/djangoapps/content_tagging/tests/test_rules.py b/openedx/core/djangoapps/content_tagging/tests/test_rules.py
index 442fda5a71..9c1187ab91 100644
--- a/openedx/core/djangoapps/content_tagging/tests/test_rules.py
+++ b/openedx/core/djangoapps/content_tagging/tests/test_rules.py
@@ -2,18 +2,16 @@
import ddt
from django.contrib.auth import get_user_model
-from django.test.testcases import TestCase, override_settings
-from opaque_keys import InvalidKeyError
+from django.test import TestCase
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from openedx_tagging.core.tagging.models import (
Tag,
UserSystemDefinedTaxonomy,
)
-from openedx_tagging.core.tagging.rules import ChangeObjectTagPermissionItem
-from organizations.models import Organization
+from openedx_tagging.core.tagging.rules import ObjectTagPermissionItem
from common.djangoapps.student.auth import add_users, update_org_role
-from common.djangoapps.student.roles import CourseCreatorRole, CourseStaffRole, OrgContentCreatorRole
+from common.djangoapps.student.roles import CourseStaffRole, OrgStaffRole
from .. import api
from .test_api import TestTaxonomyMixin
@@ -22,7 +20,6 @@ User = get_user_model()
@ddt.ddt
-@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": True})
class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
"""
Tests that the expected rules have been applied to the Taxonomy models.
@@ -42,12 +39,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
email="staff@example.com",
is_staff=True,
)
- # Normal user: grant course creator role (for all orgs)
- self.user_all_orgs = User.objects.create(
- username="user_all_orgs",
- email="staff+all@example.com",
- )
- add_users(self.staff, CourseCreatorRole(), self.user_all_orgs)
# Normal user: grant course creator access to both org1 and org2
self.user_both_orgs = User.objects.create(
@@ -56,7 +47,7 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
)
update_org_role(
self.staff,
- OrgContentCreatorRole,
+ OrgStaffRole,
self.user_both_orgs,
[self.org1.short_name, self.org2.short_name],
)
@@ -67,7 +58,7 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
email="staff+org2@example.com",
)
update_org_role(
- self.staff, OrgContentCreatorRole, self.user_org2, [self.org2.short_name]
+ self.staff, OrgStaffRole, self.user_org2, [self.org2.short_name]
)
# Normal user: no course creator access
@@ -96,84 +87,69 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
block_id='block_id'
)
- add_users(self.staff, CourseStaffRole(self.course1), self.user_all_orgs)
add_users(self.staff, CourseStaffRole(self.course1), self.user_both_orgs)
- add_users(self.staff, CourseStaffRole(self.course2), self.user_all_orgs)
add_users(self.staff, CourseStaffRole(self.course2), self.user_both_orgs)
add_users(self.staff, CourseStaffRole(self.course2), self.user_org2)
add_users(self.staff, CourseStaffRole(self.course2), self.user_org2)
- self.tax_all_course1 = ChangeObjectTagPermissionItem(
+ self.tax_all_course1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_all_orgs,
object_id=str(self.course1),
)
- self.tax_all_course2 = ChangeObjectTagPermissionItem(
+ self.tax_all_course2 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_all_orgs,
object_id=str(self.course2),
)
- self.tax_all_xblock1 = ChangeObjectTagPermissionItem(
+ self.tax_all_xblock1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_all_orgs,
object_id=str(self.xblock1),
)
- self.tax_all_xblock2 = ChangeObjectTagPermissionItem(
+ self.tax_all_xblock2 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_all_orgs,
object_id=str(self.xblock2),
)
- self.tax_both_course1 = ChangeObjectTagPermissionItem(
+ self.tax_both_course1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_both_orgs,
object_id=str(self.course1),
)
- self.tax_both_course2 = ChangeObjectTagPermissionItem(
+ self.tax_both_course2 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_both_orgs,
object_id=str(self.course2),
)
- self.tax_both_xblock1 = ChangeObjectTagPermissionItem(
+ self.tax_both_xblock1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_both_orgs,
object_id=str(self.xblock1),
)
- self.tax_both_xblock2 = ChangeObjectTagPermissionItem(
+ self.tax_both_xblock2 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_both_orgs,
object_id=str(self.xblock2),
)
- self.tax1_course1 = ChangeObjectTagPermissionItem(
+ self.tax1_course1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_one_org,
object_id=str(self.course1),
)
- self.tax1_xblock1 = ChangeObjectTagPermissionItem(
+ self.tax1_xblock1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_one_org,
object_id=str(self.xblock1),
)
- self.tax_no_org_course1 = ChangeObjectTagPermissionItem(
+ self.tax_no_org_course1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_no_orgs,
object_id=str(self.course1),
)
- self.tax_no_org_xblock1 = ChangeObjectTagPermissionItem(
+ self.tax_no_org_xblock1 = ObjectTagPermissionItem(
taxonomy=self.taxonomy_no_orgs,
object_id=str(self.xblock1),
)
- self.disabled_course_tag_perm = ChangeObjectTagPermissionItem(
+ self.disabled_course2_tag_perm = ObjectTagPermissionItem(
taxonomy=self.taxonomy_disabled,
object_id=str(self.course2),
)
- self.all_orgs_invalid_tag_perm = ChangeObjectTagPermissionItem(
- taxonomy=self.taxonomy_all_orgs,
- object_id="course-v1_OpenedX_DemoX_Demo_Course",
- )
- self.one_org_invalid_org_tag_perm = ChangeObjectTagPermissionItem(
- taxonomy=self.taxonomy_one_org,
- object_id="block-v1_OeX_DemoX_Demo_Course_type_html_block@abcde",
- )
- self.no_orgs_invalid_tag_perm = ChangeObjectTagPermissionItem(
- taxonomy=self.taxonomy_no_orgs,
- object_id=str(self.course1),
- )
-
self.all_org_perms = (
self.tax_all_course1,
self.tax_all_course2,
@@ -198,8 +174,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
assert self.superuser.has_perm(perm, obj)
assert self.staff.has_perm(perm)
assert self.staff.has_perm(perm, obj)
- assert self.user_all_orgs.has_perm(perm)
- assert self.user_all_orgs.has_perm(perm, obj)
# Org content creators are bound by a taxonomy's org restrictions
assert self.user_both_orgs.has_perm(perm) == learner_perm
@@ -213,58 +187,78 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
assert self.learner.has_perm(perm, obj) == learner_obj
# Taxonomy
+ def test_taxonomy_base_add_permissions(self):
+ """
+ Test that staff, superuser and org admins can call POST on taxonomies.
+ """
+ perm = "oel_tagging.add_taxonomy"
+ assert self.superuser.has_perm(perm)
+ assert self.staff.has_perm(perm)
+ assert self.user_both_orgs.has_perm(perm)
+ assert self.user_org2.has_perm(perm)
+ assert not self.learner.has_perm(perm)
@ddt.data(
- "oel_tagging.add_taxonomy",
"oel_tagging.change_taxonomy",
"oel_tagging.delete_taxonomy",
)
def test_taxonomy_base_edit_permissions(self, perm):
"""
- Test that only Staff & Superuser can call add/edit/delete taxonomies.
+ Test that everyone can call PUT, PATCH and DELETE on taxonomies.
"""
assert self.superuser.has_perm(perm)
assert self.staff.has_perm(perm)
- assert not self.user_all_orgs.has_perm(perm)
- assert not self.user_both_orgs.has_perm(perm)
- assert not self.user_org2.has_perm(perm)
- assert not self.learner.has_perm(perm)
+ assert self.user_both_orgs.has_perm(perm)
+ assert self.user_org2.has_perm(perm)
+ assert self.learner.has_perm(perm)
@ddt.data(
"oel_tagging.view_taxonomy",
)
def test_taxonomy_base_view_permissions(self, perm):
"""
- Test that everyone can call view taxonomy.
+ Test that everyone can call GET on taxonomies.
"""
assert self.superuser.has_perm(perm)
assert self.staff.has_perm(perm)
- assert self.user_all_orgs.has_perm(perm)
assert self.user_both_orgs.has_perm(perm)
assert self.user_org2.has_perm(perm)
assert self.learner.has_perm(perm)
@ddt.data(
- ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"),
("oel_tagging.change_taxonomy", "taxonomy_disabled"),
("oel_tagging.change_taxonomy", "taxonomy_both_orgs"),
("oel_tagging.change_taxonomy", "taxonomy_one_org"),
- ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"),
- ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"),
("oel_tagging.delete_taxonomy", "taxonomy_disabled"),
("oel_tagging.delete_taxonomy", "taxonomy_both_orgs"),
("oel_tagging.delete_taxonomy", "taxonomy_one_org"),
- ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"),
)
@ddt.unpack
def test_change_taxonomy(self, perm, taxonomy_attr):
"""
- Test that only Staff & Superuser can edit/delete taxonomies.
+ Test that only instance level and org level admins can edit/delete taxonomies from their orgs.
+ """
+ taxonomy = getattr(self, taxonomy_attr)
+ assert self.superuser.has_perm(perm, taxonomy)
+ assert self.staff.has_perm(perm, taxonomy)
+ assert self.user_both_orgs.has_perm(perm, taxonomy)
+ assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org")
+ assert not self.learner.has_perm(perm, taxonomy)
+
+ @ddt.data(
+ ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"),
+ ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"),
+ ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"),
+ ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"),
+ )
+ @ddt.unpack
+ def test_change_taxonomy_all_no_org(self, perm, taxonomy_attr):
+ """
+ Test that only Staff & Superuser can edit/delete taxonomies from all or no org.
"""
taxonomy = getattr(self, taxonomy_attr)
assert self.superuser.has_perm(perm, taxonomy)
assert self.staff.has_perm(perm, taxonomy)
- assert not self.user_all_orgs.has_perm(perm, taxonomy)
assert not self.user_both_orgs.has_perm(perm, taxonomy)
assert not self.user_org2.has_perm(perm, taxonomy)
assert not self.learner.has_perm(perm, taxonomy)
@@ -284,20 +278,31 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
system_taxonomy = system_taxonomy.cast()
assert self.superuser.has_perm(perm, system_taxonomy)
assert not self.staff.has_perm(perm, system_taxonomy)
- assert not self.user_all_orgs.has_perm(perm, system_taxonomy)
assert not self.user_both_orgs.has_perm(perm, system_taxonomy)
assert not self.user_org2.has_perm(perm, system_taxonomy)
assert not self.learner.has_perm(perm, system_taxonomy)
+ def test_view_taxonomy_no_orgs(self):
+ """
+ Test that only Staff & Superuser can view taxonomies with no orgs.
+ """
+ taxonomy = self.taxonomy_no_orgs
+ taxonomy.enabled = True
+ perm = "oel_tagging.view_taxonomy"
+
+ assert self.superuser.has_perm(perm, taxonomy)
+ assert self.staff.has_perm(perm, taxonomy)
+ assert not self.user_both_orgs.has_perm(perm, taxonomy)
+ assert not self.user_org2.has_perm(perm, taxonomy)
+ assert not self.learner.has_perm(perm, taxonomy)
+
@ddt.data(
- "taxonomy_all_orgs",
"taxonomy_both_orgs",
"taxonomy_one_org",
- "taxonomy_no_orgs",
)
def test_view_taxonomy_enabled(self, taxonomy_attr):
"""
- Test that anyone can view enabled taxonomies.
+ Test that anyone can view enabled taxonomies from their org.
"""
taxonomy = getattr(self, taxonomy_attr)
taxonomy.enabled = True
@@ -305,20 +310,31 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
assert self.superuser.has_perm(perm, taxonomy)
assert self.staff.has_perm(perm, taxonomy)
- assert self.user_all_orgs.has_perm(perm, taxonomy)
+ assert self.user_both_orgs.has_perm(perm, taxonomy)
+ assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org")
+ assert not self.learner.has_perm(perm, taxonomy)
+
+ def test_view_taxonomy_enabled_all_orgs(self):
+ """
+ Test that anyone can view enabled global taxonomies.
+ """
+ taxonomy = self.taxonomy_all_orgs
+ taxonomy.enabled = True
+ perm = "oel_tagging.view_taxonomy"
+
+ assert self.superuser.has_perm(perm, taxonomy)
+ assert self.staff.has_perm(perm, taxonomy)
assert self.user_both_orgs.has_perm(perm, taxonomy)
assert self.user_org2.has_perm(perm, taxonomy)
assert self.learner.has_perm(perm, taxonomy)
@ddt.data(
- "taxonomy_all_orgs",
"taxonomy_both_orgs",
"taxonomy_one_org",
- "taxonomy_no_orgs",
)
def test_view_taxonomy_disabled(self, taxonomy_attr):
"""
- Test that only Staff & Superuser can view disabled taxonomies.
+ Test that only instance level and org level admins can view disabled taxonomies.
"""
taxonomy = getattr(self, taxonomy_attr)
taxonomy.enabled = False
@@ -326,7 +342,34 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
assert self.superuser.has_perm(perm, taxonomy)
assert self.staff.has_perm(perm, taxonomy)
- assert not self.user_all_orgs.has_perm(perm, taxonomy)
+ assert self.user_both_orgs.has_perm(perm, taxonomy)
+ assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org")
+ assert not self.learner.has_perm(perm, taxonomy)
+
+ def test_view_taxonomy_all_orgs_disabled(self):
+ """
+ Test that only instance level admins can view disabled all org taxonomies.
+ """
+ taxonomy = self.taxonomy_all_orgs
+ taxonomy.enabled = False
+ perm = "oel_tagging.view_taxonomy"
+
+ assert self.superuser.has_perm(perm, taxonomy)
+ assert self.staff.has_perm(perm, taxonomy)
+ assert not self.user_both_orgs.has_perm(perm, taxonomy)
+ assert not self.user_org2.has_perm(perm, taxonomy)
+ assert not self.learner.has_perm(perm, taxonomy)
+
+ def test_view_taxonomy_disabled_no_org(self):
+ """
+ Test that only Staff & Superuser can view disabled taxonomies with no orgs.
+ """
+ taxonomy = self.taxonomy_no_orgs
+ taxonomy.enabled = False
+ perm = "oel_tagging.view_taxonomy"
+
+ assert self.superuser.has_perm(perm, taxonomy)
+ assert self.staff.has_perm(perm, taxonomy)
assert not self.user_both_orgs.has_perm(perm, taxonomy)
assert not self.user_org2.has_perm(perm, taxonomy)
assert not self.learner.has_perm(perm, taxonomy)
@@ -344,21 +387,17 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
"""
assert self.superuser.has_perm(perm)
assert self.staff.has_perm(perm)
- assert not self.user_all_orgs.has_perm(perm)
assert not self.user_both_orgs.has_perm(perm)
assert not self.user_org2.has_perm(perm)
assert not self.learner.has_perm(perm)
- @ddt.data(
- "oel_tagging.view_tag",
- )
- def test_tag_base_view_permissions(self, perm):
+ def test_tag_base_view_permissions(self):
"""
Test that everyone can call view tag.
"""
+ perm = "oel_tagging.view_tag"
assert self.superuser.has_perm(perm)
assert self.staff.has_perm(perm)
- assert self.user_all_orgs.has_perm(perm)
assert self.user_both_orgs.has_perm(perm)
assert self.user_org2.has_perm(perm)
assert self.learner.has_perm(perm)
@@ -383,7 +422,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
tag = getattr(self, tag_attr)
assert self.superuser.has_perm(perm, tag)
assert self.staff.has_perm(perm, tag)
- assert not self.user_all_orgs.has_perm(perm, tag)
assert not self.user_both_orgs.has_perm(perm, tag)
assert not self.user_org2.has_perm(perm, tag)
assert not self.learner.has_perm(perm, tag)
@@ -408,7 +446,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
assert self.superuser.has_perm(perm, tag_system_taxonomy)
assert not self.staff.has_perm(perm, tag_system_taxonomy)
- assert not self.user_all_orgs.has_perm(perm, tag_system_taxonomy)
assert not self.user_both_orgs.has_perm(perm, tag_system_taxonomy)
assert not self.user_org2.has_perm(perm, tag_system_taxonomy)
assert not self.learner.has_perm(perm, tag_system_taxonomy)
@@ -433,7 +470,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
assert self.superuser.has_perm(perm, tag_free_text_taxonomy)
assert not self.staff.has_perm(perm, tag_free_text_taxonomy)
- assert not self.user_all_orgs.has_perm(perm, tag_free_text_taxonomy)
assert not self.user_both_orgs.has_perm(perm, tag_free_text_taxonomy)
assert not self.user_org2.has_perm(perm, tag_free_text_taxonomy)
assert not self.learner.has_perm(perm, tag_free_text_taxonomy)
@@ -451,7 +487,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
assert self.staff.has_perm(perm, tag)
# Everyone else can't do anything
- assert not self.user_all_orgs.has_perm(perm, tag)
assert not self.user_both_orgs.has_perm(perm, tag)
assert not self.user_org2.has_perm(perm, tag)
assert not self.learner.has_perm(perm, tag)
@@ -473,28 +508,29 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
# ObjectTag
@ddt.data(
- ("oel_tagging.add_object_tag", "disabled_course_tag_perm"),
- ("oel_tagging.change_object_tag", "disabled_course_tag_perm"),
- ("oel_tagging.delete_object_tag", "disabled_course_tag_perm"),
+ ("oel_tagging.add_objecttag", "disabled_course2_tag_perm"),
+ ("oel_tagging.change_objecttag", "disabled_course2_tag_perm"),
+ ("oel_tagging.delete_objecttag", "disabled_course2_tag_perm"),
)
@ddt.unpack
def test_object_tag_disabled_taxonomy(self, perm, tag_attr):
- """Only taxonomy administrators can create/edit an ObjectTag using a disabled Taxonomy"""
+ """
+ Only superuser create/edit an ObjectTag using a disabled Taxonomy
+ """
object_tag_perm = getattr(self, tag_attr)
assert self.superuser.has_perm(perm, object_tag_perm)
- assert self.staff.has_perm(perm, object_tag_perm)
- assert not self.user_all_orgs.has_perm(perm, object_tag_perm)
+ assert not self.staff.has_perm(perm, object_tag_perm)
assert not self.user_both_orgs.has_perm(perm, object_tag_perm)
assert not self.user_org2.has_perm(perm, object_tag_perm)
assert not self.learner.has_perm(perm, object_tag_perm)
@ddt.data(
- ("oel_tagging.add_object_tag", "tax_no_org_course1"),
- ("oel_tagging.add_object_tag", "tax_no_org_xblock1"),
- ("oel_tagging.change_object_tag", "tax_no_org_course1"),
- ("oel_tagging.change_object_tag", "tax_no_org_xblock1"),
- ("oel_tagging.delete_object_tag", "tax_no_org_xblock1"),
- ("oel_tagging.delete_object_tag", "tax_no_org_course1"),
+ ("oel_tagging.add_objecttag", "tax_no_org_course1"),
+ ("oel_tagging.add_objecttag", "tax_no_org_xblock1"),
+ ("oel_tagging.change_objecttag", "tax_no_org_course1"),
+ ("oel_tagging.change_objecttag", "tax_no_org_xblock1"),
+ ("oel_tagging.delete_objecttag", "tax_no_org_xblock1"),
+ ("oel_tagging.delete_objecttag", "tax_no_org_course1"),
)
@ddt.unpack
def test_object_tag_no_orgs(self, perm, tag_attr):
@@ -502,15 +538,14 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
object_tag = getattr(self, tag_attr)
assert self.superuser.has_perm(perm, object_tag)
assert self.staff.has_perm(perm, object_tag)
- assert not self.user_all_orgs.has_perm(perm, object_tag)
assert not self.user_both_orgs.has_perm(perm, object_tag)
assert not self.user_org2.has_perm(perm, object_tag)
assert not self.learner.has_perm(perm, object_tag)
@ddt.data(
- "oel_tagging.add_object_tag",
- "oel_tagging.change_object_tag",
- "oel_tagging.delete_object_tag",
+ "oel_tagging.add_objecttag",
+ "oel_tagging.change_objecttag",
+ "oel_tagging.delete_objecttag",
)
def test_change_object_tag_all_orgs(self, perm):
"""
@@ -520,18 +555,17 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
for perm_item in self.all_org_perms:
assert self.superuser.has_perm(perm, perm_item)
assert self.staff.has_perm(perm, perm_item)
- assert self.user_all_orgs.has_perm(perm, perm_item)
assert self.user_both_orgs.has_perm(perm, perm_item)
assert self.user_org2.has_perm(perm, perm_item) == (self.org2.short_name in perm_item.object_id)
assert not self.learner.has_perm(perm, perm_item)
@ddt.data(
- ("oel_tagging.add_object_tag", "tax1_course1"),
- ("oel_tagging.add_object_tag", "tax1_xblock1"),
- ("oel_tagging.change_object_tag", "tax1_course1"),
- ("oel_tagging.change_object_tag", "tax1_xblock1"),
- ("oel_tagging.delete_object_tag", "tax1_course1"),
- ("oel_tagging.delete_object_tag", "tax1_xblock1"),
+ ("oel_tagging.add_objecttag", "tax1_course1"),
+ ("oel_tagging.add_objecttag", "tax1_xblock1"),
+ ("oel_tagging.change_objecttag", "tax1_course1"),
+ ("oel_tagging.change_objecttag", "tax1_xblock1"),
+ ("oel_tagging.delete_objecttag", "tax1_course1"),
+ ("oel_tagging.delete_objecttag", "tax1_xblock1"),
)
@ddt.unpack
def test_change_object_tag_org1(self, perm, tag_attr):
@@ -539,101 +573,37 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase):
perm_item = getattr(self, tag_attr)
assert self.superuser.has_perm(perm, perm_item)
assert self.staff.has_perm(perm, perm_item)
- assert self.user_all_orgs.has_perm(perm, perm_item)
assert self.user_both_orgs.has_perm(perm, perm_item)
assert not self.user_org2.has_perm(perm, perm_item)
assert not self.learner.has_perm(perm, perm_item)
@ddt.data(
-
- ("oel_tagging.add_object_tag", "one_org_invalid_org_tag_perm"),
- ("oel_tagging.add_object_tag", "all_orgs_invalid_tag_perm"),
- ("oel_tagging.change_object_tag", "one_org_invalid_org_tag_perm"),
- ("oel_tagging.change_object_tag", "all_orgs_invalid_tag_perm"),
- ("oel_tagging.delete_object_tag", "one_org_invalid_org_tag_perm"),
- ("oel_tagging.delete_object_tag", "all_orgs_invalid_tag_perm"),
- )
- @ddt.unpack
- def test_change_object_tag_invalid_key(self, perm, tag_attr):
- perm_item = getattr(self, tag_attr)
- with self.assertRaises(InvalidKeyError):
- assert self.staff.has_perm(perm, perm_item)
-
- @ddt.data(
- "all_orgs_course_tag",
- "all_orgs_block_tag",
- "both_orgs_course_tag",
- "both_orgs_block_tag",
- "one_org_block_tag",
- "all_orgs_invalid_tag",
- "one_org_invalid_org_tag",
- "no_orgs_invalid_tag",
- "disabled_course_tag",
+ "tax_all_course1",
+ "tax_all_course2",
+ "tax_all_xblock1",
+ "tax_all_xblock2",
+ "tax_both_course1",
+ "tax_both_course2",
+ "tax_both_xblock1",
+ "tax_both_xblock2",
)
def test_view_object_tag(self, tag_attr):
"""Anyone can view any ObjectTag"""
- object_tag = getattr(self, tag_attr)
- self._expected_users_have_perm(
- "oel_tagging.view_object_tag",
- object_tag,
- learner_perm=True,
- learner_obj=True,
- )
+ perm = "oel_tagging.view_objecttag"
+ perm_item = getattr(self, tag_attr)
+ assert self.superuser.has_perm(perm, perm_item)
+ assert self.staff.has_perm(perm, perm_item)
+ assert self.user_both_orgs.has_perm(perm, perm_item)
+ assert self.user_org2.has_perm(perm, perm_item) == tag_attr.endswith("2")
+ assert not self.learner.has_perm(perm, perm_item)
-
-@ddt.ddt
-@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": False})
-class TestRulesTaxonomyNoCreatorGroup(
- TestRulesTaxonomy
-): # pylint: disable=test-inherits-tests
- """
- Run the above tests with ENABLE_CREATOR_GROUP unset, to demonstrate that all users have course creator access for
- all orgs, and therefore everyone is a Taxonomy Administrator.
-
- However, if there are no Organizations in the database, then nobody has access to the Tagging models.
- """
-
- def _expected_users_have_perm(
- self, perm, obj, learner_perm=False, learner_obj=False, user_org2=True
- ):
+ def test_view_object_tag_diabled(self):
"""
- When ENABLE_CREATOR_GROUP is disabled, all users have all permissions.
+ Noboty can view a ObjectTag from a disable taxonomy
"""
- super()._expected_users_have_perm(
- perm=perm,
- obj=obj,
- learner_perm=learner_perm,
- learner_obj=learner_obj,
- user_org2=user_org2,
- )
-
- # Taxonomy
-
- @ddt.data(
- ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"),
- ("oel_tagging.change_taxonomy", "taxonomy_both_orgs"),
- ("oel_tagging.change_taxonomy", "taxonomy_disabled"),
- ("oel_tagging.change_taxonomy", "taxonomy_one_org"),
- ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"),
- ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"),
- ("oel_tagging.delete_taxonomy", "taxonomy_both_orgs"),
- ("oel_tagging.delete_taxonomy", "taxonomy_disabled"),
- ("oel_tagging.delete_taxonomy", "taxonomy_one_org"),
- ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"),
- )
- @ddt.unpack
- def test_no_orgs_no_perms(self, perm, taxonomy_attr):
- """
- Org-level permissions are revoked when there are no orgs.
- """
- Organization.objects.all().delete()
- taxonomy = getattr(self, taxonomy_attr)
- # Superusers & Staff always have access
- assert self.superuser.has_perm(perm, taxonomy)
- assert self.staff.has_perm(perm, taxonomy)
-
- # But everyone else's object-level access is removed
- assert not self.user_all_orgs.has_perm(perm, taxonomy)
- assert not self.user_both_orgs.has_perm(perm, taxonomy)
- assert not self.user_org2.has_perm(perm, taxonomy)
- assert not self.learner.has_perm(perm, taxonomy)
+ perm = "oel_tagging.view_objecttag"
+ assert self.superuser.has_perm(perm, self.disabled_course_tag)
+ assert not self.staff.has_perm(perm, self.disabled_course_tag)
+ assert not self.user_both_orgs.has_perm(perm, self.disabled_course_tag)
+ assert not self.user_org2.has_perm(perm, self.disabled_course_tag)
+ assert not self.learner.has_perm(perm, self.disabled_course_tag)
diff --git a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py
index c1c45e9449..fccac3e307 100644
--- a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py
+++ b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py
@@ -5,10 +5,9 @@ from __future__ import annotations
from unittest.mock import patch
-from django.core.management import call_command
from django.test import override_settings
from edx_toggles.toggles.testutils import override_waffle_flag
-from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy
+from openedx_tagging.core.tagging.models import Tag, Taxonomy
from organizations.models import Organization
from common.djangoapps.student.tests.factories import UserFactory
@@ -16,28 +15,59 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase
from .. import api
-from ..models import ContentLanguageTaxonomy, TaxonomyOrg
+from ..models.base import TaxonomyOrg
from ..toggles import CONTENT_TAGGING_AUTO
+from ..types import ContentKey
LANGUAGE_TAXONOMY_ID = -1
+class LanguageTaxonomyTestMixin:
+ """
+ Mixin for test cases that expect the Language System Taxonomy to exist.
+ """
+
+ def setUp(self):
+ """
+ When pytest runs, it creates the database by inspecting models, not by
+ running migrations. So data created by our migrations is not present.
+ In particular, the Language Taxonomy is not present. So this mixin will
+ create the taxonomy, simulating the effect of the following migrations:
+ 1. openedx_tagging.core.tagging.migrations.0012_language_taxonomy
+ 2. content_tagging.migrations.0007_system_defined_org_2
+ """
+ super().setUp()
+ Taxonomy.objects.get_or_create(id=-1, defaults={
+ "name": "Languages",
+ "description": "Languages that are enabled on this system.",
+ "enabled": True,
+ "allow_multiple": False,
+ "allow_free_text": False,
+ "visible_to_authors": True,
+ "_taxonomy_class": "openedx_tagging.core.tagging.models.system_defined.LanguageTaxonomy",
+ })
+ TaxonomyOrg.objects.get_or_create(taxonomy_id=-1, defaults={"org": None})
+
+
@skip_unless_cms # Auto-tagging is only available in the CMS
@override_waffle_flag(CONTENT_TAGGING_AUTO, active=True)
-class TestAutoTagging(ModuleStoreTestCase):
+class TestAutoTagging(LanguageTaxonomyTestMixin, ModuleStoreTestCase): # type: ignore[misc]
"""
Test if the Course and XBlock tags are automatically created
"""
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
- def _check_tag(self, object_id: str, taxonomy_id: int, value: str | None):
+ def _check_tag(self, object_key: ContentKey, taxonomy_id: int, value: str | None):
"""
Check if the ObjectTag exists for the given object_id and taxonomy_id
If value is None, check if the ObjectTag does not exists
"""
- object_tag = ObjectTag.objects.filter(object_id=object_id, taxonomy_id=taxonomy_id).first()
+ object_tags = list(api.get_content_tags(object_key, taxonomy_id=taxonomy_id))
+ object_tag = object_tags[0] if len(object_tags) == 1 else None
+ if len(object_tags) > 1:
+ raise ValueError("Found too many object tags")
if value is None:
assert not object_tag, f"Expected no tag for taxonomy_id={taxonomy_id}, " \
f"but one found with value={object_tag.value}"
@@ -47,21 +77,6 @@ class TestAutoTagging(ModuleStoreTestCase):
return True
- @classmethod
- def setUpClass(cls):
- # Run fixtures to create the system defined tags
- call_command("loaddata", "--app=oel_tagging", "language_taxonomy.yaml")
-
- # Configure language taxonomy
- language_taxonomy = Taxonomy.objects.get(id=-1)
- language_taxonomy.taxonomy_class = ContentLanguageTaxonomy
- language_taxonomy.save()
-
- # Enable Language taxonomy for all orgs
- TaxonomyOrg.objects.create(id=-1, taxonomy=language_taxonomy, org=None)
-
- super().setUpClass()
-
def setUp(self):
super().setUp()
# Create user
@@ -80,13 +95,13 @@ class TestAutoTagging(ModuleStoreTestCase):
"test_course",
"test_run",
self.user_id,
- fields={"language": "pt"},
+ fields={"language": "pl"},
)
# Check if the tags are created in the Course
- assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese")
+ assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Polski")
- @override_settings(LANGUAGE_CODE='pt')
+ @override_settings(LANGUAGE_CODE='pt-br')
def test_create_course_invalid_language(self):
# Create course
course = self.store.create_course(
@@ -98,9 +113,9 @@ class TestAutoTagging(ModuleStoreTestCase):
)
# Check if the tags are created in the Course is the system default
- assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese")
+ assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Português (Brasil)")
- @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_CODE='pt')
+ @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_DICT={'pt': 'Portuguese'}, LANGUAGE_CODE='pt')
def test_create_course_unsuported_language(self):
# Create course
course = self.store.create_course(
@@ -114,22 +129,6 @@ class TestAutoTagging(ModuleStoreTestCase):
# Check if the tags are created in the Course is the system default
assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese")
- @override_settings(LANGUAGE_CODE='pt')
- def test_create_course_no_tag_language(self):
- # Remove English tag
- Tag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID, value="English").delete()
- # Create course
- course = self.store.create_course(
- self.orgA.short_name,
- "test_course",
- "test_run",
- self.user_id,
- fields={"language": "en"},
- )
-
- # Check if the tags are created in the Course is the system default
- assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese")
-
@override_settings(LANGUAGE_CODE='pt')
def test_create_course_no_tag_default_language(self):
# Remove Portuguese tag
@@ -153,19 +152,19 @@ class TestAutoTagging(ModuleStoreTestCase):
"test_course",
"test_run",
self.user_id,
- fields={"language": "pt"},
+ fields={"language": "pt-br"},
)
# Simulates user manually changing a tag
lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID)
- api.tag_content_object(lang_taxonomy, ["Spanish"], course.id)
+ api.tag_content_object(course.id, lang_taxonomy, ["Español (España)"])
# Update course language
course.language = "en"
self.store.update_item(course, self.user_id)
# Does not automatically update the tag
- assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Spanish")
+ assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Español (España)")
def test_create_delete_xblock(self):
# Create course
@@ -174,7 +173,7 @@ class TestAutoTagging(ModuleStoreTestCase):
"test_course",
"test_run",
self.user_id,
- fields={"language": "pt"},
+ fields={"language": "pt-br"},
)
# Create XBlocks
@@ -184,7 +183,7 @@ class TestAutoTagging(ModuleStoreTestCase):
usage_key_str = str(vertical.location)
# Check if the tags are created in the XBlock
- assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Portuguese")
+ assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Português (Brasil)")
# Delete the XBlock
self.store.delete_item(vertical.location, self.user_id)
diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py
new file mode 100644
index 0000000000..3a9f6ec549
--- /dev/null
+++ b/openedx/core/djangoapps/content_tagging/types.py
@@ -0,0 +1,8 @@
+"""
+Types used by content tagging API and implementation
+"""
+from typing import Union
+
+from opaque_keys.edx.keys import LearningContextKey, UsageKey
+
+ContentKey = Union[LearningContextKey, UsageKey]
diff --git a/openedx/core/djangoapps/contentserver/test/test_contentserver.py b/openedx/core/djangoapps/contentserver/test/test_contentserver.py
index 700eff6427..e9ee1f7ee5 100644
--- a/openedx/core/djangoapps/contentserver/test/test_contentserver.py
+++ b/openedx/core/djangoapps/contentserver/test/test_contentserver.py
@@ -156,7 +156,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
CourseEnrollment.enroll(self.non_staff_usr, self.course_key)
assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key)
- self.client.login(username=self.non_staff_usr, password='test')
+ self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD)
resp = self.client.get(self.url_locked_versioned)
assert resp.status_code == 200
@@ -167,7 +167,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
CourseEnrollment.enroll(self.non_staff_usr, self.course_key)
assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key)
- self.client.login(username=self.non_staff_usr, password='test')
+ self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD)
resp = self.client.get(self.url_locked_versioned_old_style, follow=True)
assert resp.status_code == 200
@@ -185,7 +185,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
Test that locked assets behave appropriately in case user is logged in
in but not registered for the course.
"""
- self.client.login(username=self.non_staff_usr, password='test')
+ self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD)
resp = self.client.get(self.url_locked)
assert resp.status_code == 403
@@ -197,7 +197,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
CourseEnrollment.enroll(self.non_staff_usr, self.course_key)
assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key)
- self.client.login(username=self.non_staff_usr, password='test')
+ self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD)
resp = self.client.get(self.url_locked)
assert resp.status_code == 200
@@ -205,7 +205,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
"""
Test that locked assets behave appropriately in case user is staff.
"""
- self.client.login(username=self.staff_usr, password='test')
+ self.client.login(username=self.staff_usr, password=self.TEST_PASSWORD)
resp = self.client.get(self.url_locked)
assert resp.status_code == 200
@@ -320,7 +320,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
CourseEnrollment.enroll(self.non_staff_usr, self.course_key)
assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key)
- self.client.login(username=self.non_staff_usr, password='test')
+ self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD)
resp = self.client.get(self.url_locked)
assert resp.status_code == 200
assert 'Expires' not in resp
@@ -350,7 +350,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase):
CourseEnrollment.enroll(self.non_staff_usr, self.course_key)
assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key)
- self.client.login(username=self.non_staff_usr, password='test')
+ self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD)
resp = self.client.get(self.url_locked)
assert resp.status_code == 200
assert 'Expires' not in resp
diff --git a/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py
index 2c1e615229..768643a7a2 100644
--- a/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py
+++ b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py
@@ -32,7 +32,7 @@ class CourseAppsRestApiTest(SharedModuleStoreTestCase):
self.instructor = UserFactory()
self.user = UserFactory()
self.client = Client()
- self.client.login(username=self.instructor.username, password="test")
+ self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD)
self.url = reverse("course_apps_api:v1:course_apps", kwargs=dict(course_id=self.course.id))
CourseStaffRole(self.course.id).add_users(self.instructor)
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py
new file mode 100644
index 0000000000..545948a092
--- /dev/null
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py
@@ -0,0 +1,50 @@
+"""
+Subscription model is used to get users who are subscribed to the main thread/post i.e.
+"""
+import logging
+
+from . import models, settings, utils
+
+log = logging.getLogger(__name__)
+
+
+class Subscription(models.Model):
+ """
+ Subscription model is used to get users who are subscribed to the main thread/post i.e.
+ """
+ # accessible_fields can be set and retrieved on the model
+ accessible_fields = [
+ '_id', 'subscriber_id', "source_id", "source_type"
+ ]
+
+ type = 'subscriber'
+ base_url = f"{settings.PREFIX}/threads"
+
+ @classmethod
+ def fetch(cls, thread_id, query_params):
+ """
+ Fetches the subscriptions for a given thread_id
+ """
+ params = {
+ 'page': query_params.get('page', 1),
+ 'per_page': query_params.get('per_page', 20),
+ 'id': thread_id
+ }
+ params.update(
+ utils.strip_blank(utils.strip_none(query_params))
+ )
+ response = utils.perform_request(
+ 'get',
+ cls.url(action='get', params=params) + "/subscriptions",
+ params,
+ metric_tags=[],
+ metric_action='subscription.get',
+ paged_results=True
+ )
+ return utils.SubscriptionsPaginatedResult(
+ collection=response.get('collection', []),
+ page=response.get('page', 1),
+ num_pages=response.get('num_pages', 1),
+ subscriptions_count=response.get('subscriptions_count', 0),
+ corrected_text=response.get('corrected_text', None)
+ )
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py
index 74e3e0eba2..6f92313b6e 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py
@@ -11,7 +11,6 @@ log = logging.getLogger(__name__)
class Thread(models.Model):
-
# accessible_fields can be set and retrieved on the model
accessible_fields = [
'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id',
diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py
index 2432ab36be..a67cdbdbc4 100644
--- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py
+++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py
@@ -131,6 +131,17 @@ class CommentClientPaginatedResult:
self.corrected_text = corrected_text
+class SubscriptionsPaginatedResult:
+ """ class for paginated results returned from comment services"""
+
+ def __init__(self, collection, page, num_pages, subscriptions_count=0, corrected_text=None):
+ self.collection = collection
+ self.page = page
+ self.num_pages = num_pages
+ self.subscriptions_count = subscriptions_count
+ self.corrected_text = corrected_text
+
+
def check_forum_heartbeat():
"""
Check the forum connection via its built-in heartbeat service and create an answer which can be used in the LMS
diff --git a/openedx/core/djangoapps/external_user_ids/apps.py b/openedx/core/djangoapps/external_user_ids/apps.py
index abf154abc3..4d3c6ca09a 100644
--- a/openedx/core/djangoapps/external_user_ids/apps.py
+++ b/openedx/core/djangoapps/external_user_ids/apps.py
@@ -11,6 +11,3 @@ class ExternalUserIDConfig(AppConfig):
Default configuration for the "openedx.core.djangoapps.credit" Django application.
"""
name = 'openedx.core.djangoapps.external_user_ids'
-
- def ready(self):
- from . import signals # pylint: disable=unused-import
diff --git a/openedx/core/djangoapps/external_user_ids/models.py b/openedx/core/djangoapps/external_user_ids/models.py
index 1c4b3943e2..09fe47c4f3 100644
--- a/openedx/core/djangoapps/external_user_ids/models.py
+++ b/openedx/core/djangoapps/external_user_ids/models.py
@@ -20,7 +20,7 @@ class ExternalIdType(TimeStampedModel):
.. no_pii:
"""
- MICROBACHELORS_COACHING = 'mb_coaching'
+
CALIPER = 'caliper'
XAPI = 'xapi'
LTI = 'lti'
diff --git a/openedx/core/djangoapps/external_user_ids/signals.py b/openedx/core/djangoapps/external_user_ids/signals.py
deleted file mode 100644
index 166303d12b..0000000000
--- a/openedx/core/djangoapps/external_user_ids/signals.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-Signal Handlers for External User Ids to be created and maintainer
-"""
-
-from logging import getLogger
-
-from django.db.models.signals import post_save
-from django.dispatch import receiver
-
-from openedx.core.djangoapps.catalog.utils import get_programs
-
-from .models import ExternalId, ExternalIdType
-
-LOGGER = getLogger(__name__)
-
-
-def _user_needs_external_id(instance, created):
- return (
- created and
- instance.user and
- not ExternalId.user_has_external_id(
- user=instance.user,
- type_name=ExternalIdType.MICROBACHELORS_COACHING)
- )
-
-
-@receiver(post_save, sender='student.CourseEnrollment')
-def create_external_id_for_microbachelors_program(
- sender, instance, created, **kwargs # pylint: disable=unused-argument
-):
- """
- Watches for post_save signal for creates on the CourseEnrollment table.
- Generate an External ID if the Enrollment is in a MicroBachelors Program
- """
- if _user_needs_external_id(instance, created):
- mb_programs = [
- program for program in get_programs(course=instance.course_id)
- if program.get('type_attrs', {}).get('coaching_supported')
- ]
- if mb_programs:
- ExternalId.add_new_user_id(
- user=instance.user,
- type_name=ExternalIdType.MICROBACHELORS_COACHING
- )
-
-
-@receiver(post_save, sender='entitlements.CourseEntitlement')
-def create_external_id_for_microbachelors_program_entitlement(
- sender, instance, created, **kwargs # pylint: disable=unused-argument
-):
- """
- Watches for post_save signal for creates on the CourseEntitlement table.
- Generate an External ID if the Entitlement is in a MicroBachelors Program
- """
- if _user_needs_external_id(instance, created):
- mb_programs = [
- program for program in get_programs(catalog_course_uuid=instance.course_uuid)
- if program.get('type_attrs', {}).get('coaching_supported')
- ]
- if mb_programs:
- ExternalId.add_new_user_id(
- user=instance.user,
- type_name=ExternalIdType.MICROBACHELORS_COACHING
- )
diff --git a/openedx/core/djangoapps/external_user_ids/tests/factories.py b/openedx/core/djangoapps/external_user_ids/tests/factories.py
index 86aa747bfe..aaafe27959 100644
--- a/openedx/core/djangoapps/external_user_ids/tests/factories.py
+++ b/openedx/core/djangoapps/external_user_ids/tests/factories.py
@@ -14,7 +14,7 @@ class ExternalIDTypeFactory(factory.django.DjangoModelFactory): # lint-amnesty,
class Meta:
model = ExternalIdType
- name = FuzzyChoice([ExternalIdType.MICROBACHELORS_COACHING])
+ name = FuzzyChoice([ExternalIdType.CALIPER])
description = FuzzyText()
diff --git a/openedx/core/djangoapps/external_user_ids/tests/test_signals.py b/openedx/core/djangoapps/external_user_ids/tests/test_signals.py
deleted file mode 100644
index 290c3a27ac..0000000000
--- a/openedx/core/djangoapps/external_user_ids/tests/test_signals.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""
-Signal Tests for External User Ids that are sent out of Open edX
-"""
-
-from opaque_keys.edx.keys import CourseKey
-from django.core.cache import cache
-from edx_django_utils.cache import RequestCache
-
-from common.djangoapps.entitlements.models import CourseEntitlement
-from openedx.core.djangoapps.catalog.tests.factories import (
- CourseFactory,
- ProgramFactory,
-)
-from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory, CourseEnrollmentFactory
-from openedx.core.djangoapps.catalog.cache import (
- CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL,
- COURSE_PROGRAMS_CACHE_KEY_TPL,
- PROGRAM_CACHE_KEY_TPL,
-)
-from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
-from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.course_modes.models import CourseMode
-
-from openedx.core.djangolib.testing.utils import CacheIsolationTestCase
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-
-
-class MicrobachelorsExternalIDTest(ModuleStoreTestCase, CacheIsolationTestCase):
- """
- Test cases for Signals for External User Ids
- """
- ENABLED_CACHES = ['default']
-
- @classmethod
- def setUpClass(cls):
- super().setUpClass()
-
- cls.course_list = []
- cls.user = UserFactory.create()
- cls.course_keys = [
- CourseKey.from_string('course-v1:edX+DemoX+Test_Course'),
- CourseKey.from_string('course-v1:edX+DemoX+Another_Test_Course'),
- ]
- ExternalIdType.objects.create(
- name=ExternalIdType.MICROBACHELORS_COACHING,
- description='test'
- )
-
- def setUp(self):
- super().setUp()
- RequestCache.clear_all_namespaces()
- self.program = self._create_cached_program()
- self.client.login(username=self.user.username, password=TEST_PASSWORD)
-
- def _create_cached_program(self):
- """ helper method to create a cached program """
- program = ProgramFactory.create()
-
- for course_key in self.course_keys:
- program['courses'].append(CourseFactory(id=course_key))
-
- program['type'] = 'MicroBachelors'
- program['type_attrs']['coaching_supported'] = True
-
- for course in program['courses']:
- cache.set(
- CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=course['uuid']),
- [program['uuid']],
- None
- )
-
- course_run = course['course_runs'][0]['key']
- cache.set(
- COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=course_run),
- [program['uuid']],
- None
- )
- cache.set(
- PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']),
- program,
- None
- )
-
- return program
-
- def test_enroll_mb_create_external_id(self):
- course_run_key = self.program['courses'][0]['course_runs'][0]['key']
-
- # Enroll user
- enrollment = CourseEnrollmentFactory.create(
- course_id=course_run_key,
- user=self.user,
- mode=CourseMode.VERIFIED,
- )
- enrollment.save()
- external_id = ExternalId.objects.get(
- user=self.user
- )
- assert external_id is not None
- assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
-
- def test_second_enroll_mb_no_new_external_id(self):
- course_run_key1 = self.program['courses'][0]['course_runs'][0]['key']
- course_run_key2 = self.program['courses'][1]['course_runs'][0]['key']
-
- # Enroll user
- CourseEnrollmentFactory.create(
- course_id=course_run_key1,
- user=self.user,
- mode=CourseMode.VERIFIED,
- )
- external_id = ExternalId.objects.get(
- user=self.user
- )
- assert external_id is not None
- assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
- original_external_user_uuid = external_id.external_user_id
-
- CourseEnrollmentFactory.create(
- course_id=course_run_key2,
- user=self.user,
- mode=CourseMode.VERIFIED,
- )
- enrollments = CourseEnrollment.objects.filter(user=self.user)
-
- assert len(enrollments) == 2
-
- external_ids = ExternalId.objects.filter(
- user=self.user
- )
-
- assert len(external_ids) == 1
- assert external_ids[0].external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
- assert original_external_user_uuid == external_ids[0].external_user_id
-
- def test_entitlement_mb_create_external_id(self):
- catalog_course = self.program['courses'][0]
-
- assert ExternalId.objects.filter(
- user=self.user
- ).count() == 0
-
- entitlement = CourseEntitlement.objects.create(
- course_uuid=catalog_course['uuid'],
- mode=CourseMode.VERIFIED,
- user=self.user,
- order_number='TEST-12345'
- )
- entitlement.save()
-
- external_id = ExternalId.objects.get(
- user=self.user
- )
- assert external_id is not None
- assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
-
- def test_second_entitlement_mb_no_new_external_id(self):
- catalog_course1 = self.program['courses'][0]
- catalog_course2 = self.program['courses'][1]
-
- # Enroll user
- entitlement = CourseEntitlement.objects.create(
- course_uuid=catalog_course1['uuid'],
- mode=CourseMode.VERIFIED,
- user=self.user,
- order_number='TEST-12345'
- )
- entitlement.save()
- external_id = ExternalId.objects.get(
- user=self.user
- )
- assert external_id is not None
- assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
- original_external_user_uuid = external_id.external_user_id
-
- CourseEntitlement.objects.create(
- course_uuid=catalog_course2['uuid'],
- mode=CourseMode.VERIFIED,
- user=self.user,
- order_number='TEST-12345'
- )
- entitlements = CourseEntitlement.objects.filter(user=self.user)
-
- assert len(entitlements) == 2
-
- external_ids = ExternalId.objects.filter(
- user=self.user
- )
-
- assert len(external_ids) == 1
- assert external_ids[0].external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING
- assert original_external_user_uuid == external_ids[0].external_user_id
diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py
index ec3018c007..5541b542f8 100644
--- a/openedx/core/djangoapps/notifications/base_notification.py
+++ b/openedx/core/djangoapps/notifications/base_notification.py
@@ -3,10 +3,9 @@ Base setup for Notification Apps and Types.
"""
from django.utils.translation import gettext_lazy as _
-from .utils import (
- find_app_in_normalized_apps,
- find_pref_in_normalized_prefs,
-)
+from .utils import find_app_in_normalized_apps, find_pref_in_normalized_prefs
+
+FILTER_AUDIT_EXPIRED = 'filter_audit_expired'
COURSE_NOTIFICATION_TYPES = {
'new_comment_on_response': {
@@ -20,6 +19,7 @@ COURSE_NOTIFICATION_TYPES = {
'replier_name': 'replier name',
},
'email_template': '',
+ 'filters': [FILTER_AUDIT_EXPIRED]
},
'new_comment': {
'notification_app': 'discussion',
@@ -33,6 +33,7 @@ COURSE_NOTIFICATION_TYPES = {
'replier_name': 'replier name',
},
'email_template': '',
+ 'filters': [FILTER_AUDIT_EXPIRED]
},
'new_response': {
'notification_app': 'discussion',
@@ -45,6 +46,7 @@ COURSE_NOTIFICATION_TYPES = {
'replier_name': 'replier name',
},
'email_template': '',
+ 'filters': [FILTER_AUDIT_EXPIRED]
},
'new_discussion_post': {
'notification_app': 'discussion',
@@ -61,6 +63,7 @@ COURSE_NOTIFICATION_TYPES = {
'username': 'Post author name',
},
'email_template': '',
+ 'filters': [FILTER_AUDIT_EXPIRED]
},
'new_question_post': {
'notification_app': 'discussion',
@@ -77,7 +80,39 @@ COURSE_NOTIFICATION_TYPES = {
'username': 'Post author name',
},
'email_template': '',
- }
+ 'filters': [FILTER_AUDIT_EXPIRED]
+ },
+ 'response_on_followed_post': {
+ 'notification_app': 'discussion',
+ 'name': 'response_on_followed_post',
+ 'is_core': True,
+ 'info': '',
+ 'non_editable': [],
+ 'content_template': _('<{p}><{strong}>{replier_name}{strong}> responded to a post you’re following: '
+ '<{strong}>{post_title}{strong}>{p}>'),
+ 'content_context': {
+ 'post_title': 'Post title',
+ 'replier_name': 'replier name',
+ },
+ 'email_template': '',
+ 'filter': [FILTER_AUDIT_EXPIRED]
+ },
+ 'comment_on_followed_post': {
+ 'notification_app': 'discussion',
+ 'name': 'comment_on_followed_post',
+ 'is_core': True,
+ 'info': '',
+ 'non_editable': [],
+ 'content_template': _('<{p}><{strong}>{replier_name}{strong}> commented on {author_name}\'s response in '
+ 'a post you’re following <{strong}>{post_title}{strong}>{p}>'),
+ 'content_context': {
+ 'post_title': 'Post title',
+ 'author_name': 'author name',
+ 'replier_name': 'replier name',
+ },
+ 'email_template': '',
+ 'filter': [FILTER_AUDIT_EXPIRED]
+ },
}
COURSE_NOTIFICATION_APPS = {
@@ -322,3 +357,16 @@ def get_notification_content(notification_type, context):
if notification_type_content_template:
return notification_type_content_template.format(**context, **html_tags_context)
return ''
+
+
+def get_default_values_of_preference(notification_app, notification_type):
+ """
+ Returns default preference for notification_type
+ """
+ default_prefs = NotificationAppManager().get_notification_app_preferences()
+ app_prefs = default_prefs.get(notification_app, {})
+ core_notification_types = app_prefs.get('core_notification_types', [])
+ notification_types = app_prefs.get('notification_types', {})
+ if notification_type in core_notification_types:
+ return notification_types.get('core', {})
+ return notification_types.get(notification_type, {})
diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py
index fdfd74571c..e05487b11b 100644
--- a/openedx/core/djangoapps/notifications/config/waffle.py
+++ b/openedx/core/djangoapps/notifications/config/waffle.py
@@ -27,3 +27,13 @@ ENABLE_NOTIFICATIONS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notification
# .. toggle_target_removal_date: 2023-12-07
# .. toggle_tickets: INF-902
SHOW_NOTIFICATIONS_TRAY = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.show_notifications_tray", __name__)
+
+# .. toggle_name: notifications.enable_notifications_filters
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Waffle flag to enable filters in notifications task
+# .. toggle_use_cases: temporary, open_edx
+# .. toggle_creation_date: 2023-06-07
+# .. toggle_target_removal_date: 2024-06-01
+# .. toggle_tickets: INF-902
+ENABLE_NOTIFICATIONS_FILTERS = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_notifications_filters", __name__)
diff --git a/openedx/core/djangoapps/notifications/filters.py b/openedx/core/djangoapps/notifications/filters.py
new file mode 100644
index 0000000000..9176faa6ee
--- /dev/null
+++ b/openedx/core/djangoapps/notifications/filters.py
@@ -0,0 +1,67 @@
+"""
+Notification filters
+"""
+import logging
+
+from django.utils import timezone
+
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.student.models import CourseEnrollment
+from openedx.core.djangoapps.course_date_signals.utils import get_expected_duration
+from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_TYPES
+from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
+from xmodule.modulestore.django import modulestore
+
+logger = logging.getLogger(__name__)
+
+
+class NotificationFilter:
+ """
+ Filter notifications based on their type
+ """
+
+ @staticmethod
+ def filter_audit_expired(user_ids, course) -> list:
+ """
+ Check if the user has access to the course
+ """
+ verified_mode = CourseMode.verified_mode_for_course(course=course, include_expired=True)
+ access_duration = get_expected_duration(course.id)
+ course_time_limit = CourseDurationLimitConfig.current(course_key=course.id)
+ if not verified_mode:
+ logger.info(
+ "Course %s does not have a verified mode, so no users will be filtered out",
+ course.id,
+ )
+ return user_ids
+ enrollments = CourseEnrollment.objects.filter(
+ user_id__in=user_ids,
+ course_id=course.id,
+ mode=CourseMode.AUDIT,
+ )
+ if course_time_limit.enabled_for_course(course.id):
+ enrollments = enrollments.filter(created__gte=course_time_limit.enabled_as_of)
+
+ for enrollment in enrollments:
+ content_availability_date = max(enrollment.created, course.start)
+ expiration_date = content_availability_date + access_duration
+ if expiration_date and timezone.now() > expiration_date:
+ logger.info("User %s has expired audit access to course %s", enrollment.user_id, course.id)
+ user_ids.remove(enrollment.user_id)
+ return user_ids
+
+ def apply_filters(self, user_ids, course_key, notification_type) -> list:
+ """
+ Apply all the filters
+ """
+ notification_config = COURSE_NOTIFICATION_TYPES.get(notification_type, {})
+ applicable_filters = notification_config.get('filters', [])
+ course = modulestore().get_course(course_key)
+ for filter_name in applicable_filters:
+ logger.info(
+ "Applying filter %s for notification type %s",
+ filter_name,
+ notification_type,
+ )
+ user_ids = getattr(self, filter_name)(user_ids, course)
+ return user_ids
diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py
index d0f8daf0e5..55f6c11a7c 100644
--- a/openedx/core/djangoapps/notifications/models.py
+++ b/openedx/core/djangoapps/notifications/models.py
@@ -21,7 +21,7 @@ log = logging.getLogger(__name__)
NOTIFICATION_CHANNELS = ['web', 'push', 'email']
# Update this version when there is a change to any course specific notification type or app.
-COURSE_NOTIFICATION_CONFIG_VERSION = 3
+COURSE_NOTIFICATION_CONFIG_VERSION = 4
def get_course_notification_preference_config():
@@ -130,12 +130,12 @@ class CourseNotificationPreference(TimeStampedModel):
return f'{self.user.username} - {self.course_id}'
@staticmethod
- def get_updated_user_course_preferences(user, course_id):
+ def get_user_course_preference(user_id, course_id):
"""
Returns updated courses preferences for a user
"""
preferences, _ = CourseNotificationPreference.objects.get_or_create(
- user=user,
+ user_id=user_id,
course_id=course_id,
is_active=True,
)
@@ -149,9 +149,13 @@ class CourseNotificationPreference(TimeStampedModel):
preferences.save()
# pylint: disable-next=broad-except
except Exception as e:
- log.error(f'Unable to update notification preference for {user.username} to new config. {e}')
+ log.error(f'Unable to update notification preference to new config. {e}')
return preferences
+ @staticmethod
+ def get_updated_user_course_preferences(user, course_id):
+ return CourseNotificationPreference.get_user_course_preference(user.id, course_id)
+
def get_app_config(self, app_name) -> Dict:
"""
Returns the app config for the given app name.
diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py
index e59ff77eb1..4d9a240095 100644
--- a/openedx/core/djangoapps/notifications/tasks.py
+++ b/openedx/core/djangoapps/notifications/tasks.py
@@ -13,13 +13,16 @@ from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from common.djangoapps.student.models import CourseEnrollment
-from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
+from openedx.core.djangoapps.notifications.base_notification import get_default_values_of_preference
+from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFICATIONS_FILTERS
from openedx.core.djangoapps.notifications.events import notification_generated_event
+from openedx.core.djangoapps.notifications.filters import NotificationFilter
from openedx.core.djangoapps.notifications.models import (
CourseNotificationPreference,
Notification,
get_course_notification_preference_config_version
)
+from openedx.core.djangoapps.notifications.utils import get_list_in_batches
logger = get_task_logger(__name__)
@@ -90,25 +93,43 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
if not ENABLE_NOTIFICATIONS.is_enabled(course_key):
return
user_ids = list(set(user_ids))
+ batch_size = settings.NOTIFICATION_CREATION_BATCH_SIZE
- # check if what is preferences of user and make decision to send notification or not
- preferences = CourseNotificationPreference.objects.filter(
- user_id__in=user_ids,
- course_id=course_key,
- )
- preferences = create_notification_pref_if_not_exists(user_ids, list(preferences), course_key)
- notifications = []
- audience = []
- for preference in preferences:
- preference = update_user_preference(preference, preference.user, course_key)
- if (
- preference and
- preference.get_web_config(app_name, notification_type) and
- preference.get_app_config(app_name).get('enabled', False)
- ):
+ notifications_generated = False
+ notification_content = ''
+ default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', True)
+ for batch_user_ids in get_list_in_batches(user_ids, batch_size):
+ # check if what is preferences of user and make decision to send notification or not
+ preferences = CourseNotificationPreference.objects.filter(
+ user_id__in=batch_user_ids,
+ course_id=course_key,
+ )
+ preferences = list(preferences)
+
+ if default_web_config:
+ preferences = create_notification_pref_if_not_exists(batch_user_ids, preferences, course_key)
+
+ if not preferences:
+ return
+
+ for preference in preferences:
+ preference = update_user_preference(preference, preference.user_id, course_key)
+ if not (
+ preference and
+ preference.get_web_config(app_name, notification_type) and
+ preference.get_app_config(app_name).get('enabled', False)
+ ):
+ batch_user_ids.remove(preference.user_id)
+ if ENABLE_NOTIFICATIONS_FILTERS.is_enabled(course_key):
+ logger.info(f'Sending notifications to {len(batch_user_ids)} users.')
+ batch_user_ids = NotificationFilter().apply_filters(batch_user_ids, course_key, notification_type)
+ logger.info(f'After applying filters, sending notifications to {len(batch_user_ids)} users.')
+
+ notifications = []
+ for user_id in batch_user_ids:
notifications.append(
Notification(
- user_id=preference.user_id,
+ user_id=user_id,
app_name=app_name,
notification_type=notification_type,
content_context=context,
@@ -116,23 +137,25 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c
course_id=course_key,
)
)
- audience.append(preference.user_id)
- # send notification to users but use bulk_create
- notifications_generated = Notification.objects.bulk_create(notifications)
- if notifications_generated:
- notification_content = notifications_generated[0].content
- notification_generated_event(
- audience, app_name, notification_type, course_key, content_url, notification_content,
- )
+ # send notification to users but use bulk_create
+ notification_objects = Notification.objects.bulk_create(notifications)
+ if notification_objects and not notifications_generated:
+ notifications_generated = True
+ notification_content = notification_objects[0].content
+
+ if notifications_generated:
+ notification_generated_event(
+ batch_user_ids, app_name, notification_type, course_key, content_url, notification_content,
+ )
-def update_user_preference(preference: CourseNotificationPreference, user, course_id):
+def update_user_preference(preference: CourseNotificationPreference, user_id, course_id):
"""
Update user preference if config version is changed.
"""
current_version = get_course_notification_preference_config_version()
if preference.config_version != current_version:
- return preference.get_updated_user_course_preferences(user, course_id)
+ return preference.get_user_course_preference(user_id, course_id)
return preference
diff --git a/openedx/core/djangoapps/notifications/tests/test_filters.py b/openedx/core/djangoapps/notifications/tests/test_filters.py
new file mode 100644
index 0000000000..d1aff28de7
--- /dev/null
+++ b/openedx/core/djangoapps/notifications/tests/test_filters.py
@@ -0,0 +1,96 @@
+"""
+Test for the NotificationFilter class.
+"""
+from datetime import timedelta
+from unittest import mock
+
+import ddt
+from django.utils.timezone import now
+
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.student.models import CourseEnrollment
+from common.djangoapps.student.tests.factories import UserFactory
+from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
+from openedx.core.djangoapps.notifications.filters import NotificationFilter
+from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
+from openedx.features.course_experience.tests.views.helpers import add_course_mode
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+
+
+@ddt.ddt
+class CourseExpirationTestCase(ModuleStoreTestCase):
+ """Tests to verify the get_user_course_expiration_date function is working correctly"""
+
+ def setUp(self):
+ super().setUp() # lint-amnesty, pylint: disable=super-with-arguments
+ self.course = CourseFactory(
+ start=now() - timedelta(weeks=10),
+ )
+
+ self.user = UserFactory()
+ self.user_1 = UserFactory()
+
+ # Make this a verified course, so we can test expiration date
+ add_course_mode(self.course, mode_slug=CourseMode.AUDIT)
+ add_course_mode(self.course)
+ CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT)
+ expired_audit = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT)
+ expired_audit.created = now() - timedelta(weeks=6)
+ expired_audit.save()
+
+ @mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
+ def test_audit_expired_filter(
+ self,
+ mock_get_course_run_details,
+ ):
+ """
+ Test if filter_audit_expired function is working correctly
+ """
+
+ mock_get_course_run_details.return_value = {'weeks_to_complete': 4}
+ result = NotificationFilter.filter_audit_expired(
+ [self.user.id, self.user_1.id],
+ self.course,
+ )
+ self.assertEqual([self.user_1.id], result)
+
+ mock_get_course_run_details.return_value = {'weeks_to_complete': 7}
+ result = NotificationFilter.filter_audit_expired(
+ [self.user.id, self.user_1.id],
+ self.course,
+ )
+ self.assertEqual([self.user.id, self.user_1.id], result)
+
+ CourseDurationLimitConfig.objects.create(
+ enabled=True,
+ course=CourseOverview.get_from_id(self.course.id),
+ enabled_as_of=now(),
+ )
+ # weeks_to_complete is set to 4 because we want to test if CourseDurationLimitConfig is working correctly.
+ mock_get_course_run_details.return_value = {'weeks_to_complete': 4}
+ result = NotificationFilter.filter_audit_expired(
+ [self.user.id, self.user_1.id],
+ self.course,
+ )
+ self.assertEqual([self.user.id, self.user_1.id], result)
+
+ @mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details")
+ @mock.patch("openedx.core.djangoapps.notifications.filters.NotificationFilter.filter_audit_expired")
+ def test_apply_filter(
+ self,
+ mock_filter_audit_expired,
+ mock_get_course_run_details,
+ ):
+ """
+ Test if apply_filter function is working correctly
+ """
+ mock_get_course_run_details.return_value = {'weeks_to_complete': 4}
+ mock_filter_audit_expired.return_value = [self.user.id, self.user_1.id]
+ result = NotificationFilter().apply_filters(
+ [self.user.id, self.user_1.id],
+ self.course.id,
+ 'new_comment_on_response'
+ )
+ self.assertEqual([self.user.id, self.user_1.id], result)
+ mock_filter_audit_expired.assert_called_once()
diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py
index 51cdc741d6..339ce0e03f 100644
--- a/openedx/core/djangoapps/notifications/tests/test_tasks.py
+++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py
@@ -5,8 +5,10 @@ Tests for notifications tasks.
from unittest.mock import patch
import ddt
+from django.conf import settings
from edx_toggles.toggles.testutils import override_waffle_flag
+from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -159,3 +161,110 @@ class SendNotificationsTest(ModuleStoreTestCase):
# Assert that `Notification` objects are not created for the users.
notification = Notification.objects.filter(user_id=self.user.id).first()
self.assertIsNone(notification)
+
+
+@ddt.ddt
+@patch('openedx.core.djangoapps.notifications.tasks.ENABLE_NOTIFICATIONS_FILTERS.is_enabled', lambda x: False)
+class SendBatchNotificationsTest(ModuleStoreTestCase):
+ """
+ Test that notification and notification preferences are created in batches
+ """
+ def setUp(self):
+ """
+ Setups test case
+ """
+ super().setUp()
+ self.course = CourseFactory.create(
+ org='test_org',
+ number='test_course',
+ run='test_run'
+ )
+
+ def _create_users(self, num_of_users):
+ """
+ Create users and enroll them in course
+ """
+ users = [
+ UserFactory.create(username=f'user{i}', email=f'user{i}@example.com')
+ for i in range(num_of_users)
+ ]
+ for user in users:
+ CourseEnrollment.enroll(user=user, course_key=self.course.id)
+ return users
+
+ @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
+ @ddt.data(
+ (settings.NOTIFICATION_CREATION_BATCH_SIZE, 1, 2),
+ (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 2, 4),
+ (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 1, 2),
+ )
+ @ddt.unpack
+ def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count):
+ """
+ Tests notifications and notification preferences are created in batches
+ """
+ notification_app = "discussion"
+ notification_type = "new_discussion_post"
+ users = self._create_users(creation_size)
+ user_ids = [user.id for user in users]
+ context = {
+ "post_title": "Test Post",
+ "username": "Test Author"
+ }
+
+ # Creating preferences and asserting query count
+ with self.assertNumQueries(prefs_query_count):
+ send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
+ context, "http://test.url")
+
+ # Updating preferences for notification creation
+ preferences = CourseNotificationPreference.objects.filter(
+ user_id__in=user_ids,
+ course_id=self.course.id
+ )
+ for preference in preferences:
+ discussion_config = preference.notification_preference_config['discussion']
+ discussion_config['notification_types'][notification_type]['web'] = True
+ preference.save()
+
+ # Creating notifications and asserting query count
+ with self.assertNumQueries(notifications_query_count):
+ send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
+ context, "http://test.url")
+
+ def test_preference_not_created_for_default_off_preference(self):
+ """
+ Tests if new preferences are NOT created when default preference for
+ notification type is False
+ """
+ notification_app = "discussion"
+ notification_type = "new_discussion_post"
+ users = self._create_users(20)
+ user_ids = [user.id for user in users]
+ context = {
+ "post_title": "Test Post",
+ "username": "Test Author"
+ }
+ with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
+ with self.assertNumQueries(1):
+ send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
+ context, "http://test.url")
+
+ def test_preference_created_for_default_on_preference(self):
+ """
+ Tests if new preferences are created when default preference for
+ notification type is True
+ """
+ notification_app = "discussion"
+ notification_type = "new_comment"
+ users = self._create_users(20)
+ user_ids = [user.id for user in users]
+ context = {
+ "post_title": "Test Post",
+ "author_name": "Test Author",
+ "replier_name": "Replier Name"
+ }
+ with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True):
+ with self.assertNumQueries(3):
+ send_notifications(user_ids, str(self.course.id), notification_app, notification_type,
+ context, "http://test.url")
diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py
index 88fd9619ef..76da8756c6 100644
--- a/openedx/core/djangoapps/notifications/tests/test_views.py
+++ b/openedx/core/djangoapps/notifications/tests/test_views.py
@@ -73,7 +73,7 @@ class CourseEnrollmentListViewTest(ModuleStoreTestCase):
"""
Test the CourseEnrollmentListView.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Enable or disable the waffle flag based on the test case data
with override_waffle_flag(SHOW_NOTIFICATIONS_TRAY, active=show_notifications_tray):
url = reverse('enrollment-list')
@@ -217,7 +217,13 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
'notification_preference_config': {
'discussion': {
'enabled': True,
- 'core_notification_types': ['new_comment_on_response', 'new_comment', 'new_response'],
+ 'core_notification_types': [
+ 'new_comment_on_response',
+ 'new_comment',
+ 'new_response',
+ 'response_on_followed_post',
+ 'comment_on_followed_post'
+ ],
'notification_types': {
'core': {
'web': True,
@@ -227,7 +233,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
'following, including endorsements to your responses and on your posts.'
},
'new_discussion_post': {'web': False, 'email': False, 'push': False, 'info': ''},
- 'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''}
+ 'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''},
},
'non_editable': {
'core': ['web']
@@ -248,7 +254,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
"""
Test get user notification preference.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.path)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, self._expected_api_response())
@@ -274,7 +280,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase):
"""
Test update of user notification preference.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
payload = {
'notification_app': notification_app,
'value': value,
@@ -319,7 +325,8 @@ class NotificationListAPIViewTest(APITestCase):
"""
def setUp(self):
- self.user = UserFactory()
+ self.TEST_PASSWORD = 'Password1234'
+ self.user = UserFactory(password=self.TEST_PASSWORD)
self.url = reverse('notifications-list')
def test_list_notifications(self):
@@ -336,7 +343,7 @@ class NotificationListAPIViewTest(APITestCase):
'post_title': 'This is a test post.',
}
)
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Make a request to the view.
response = self.client.get(self.url)
@@ -373,7 +380,7 @@ class NotificationListAPIViewTest(APITestCase):
app_name='app2',
notification_type='info',
)
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Make a request to the view with the app_name query parameter set to 'app1'.
response = self.client.get(self.url + "?app_name=discussion")
@@ -396,7 +403,7 @@ class NotificationListAPIViewTest(APITestCase):
"""
Test event emission with tray_opened param is provided.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Make a request to the view with the tray_opened query parameter set to True.
response = self.client.get(self.url + "?tray_opened=True")
@@ -436,7 +443,7 @@ class NotificationListAPIViewTest(APITestCase):
notification_type='info',
created=today - timedelta(days=settings.NOTIFICATIONS_EXPIRY)
)
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Make a request to the view
response = self.client.get(self.url)
@@ -463,7 +470,7 @@ class NotificationListAPIViewTest(APITestCase):
user=self.user,
notification_type='info',
)
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Make a request to the view
response = self.client.get(self.url)
@@ -517,7 +524,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase):
"""
Test that the endpoint returns the correct count of unseen notifications and show_notifications_tray value.
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Enable or disable the waffle flag based on the test case data
with override_waffle_flag(SHOW_NOTIFICATIONS_TRAY, active=show_notifications_tray_enabled):
@@ -544,7 +551,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase):
"""
# Create a user with no notifications.
user = UserFactory()
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
@@ -556,7 +563,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase):
Tests if "notification_expiry_days" exists in API response
"""
user = UserFactory()
- self.client.login(username=user.username, password='test')
+ self.client.login(username=user.username, password=self.TEST_PASSWORD)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['notification_expiry_days'], 60)
@@ -568,7 +575,8 @@ class MarkNotificationsSeenAPIViewTestCase(APITestCase):
"""
def setUp(self):
- self.user = UserFactory()
+ self.TEST_PASSWORD = 'Password1234'
+ self.user = UserFactory(password=self.TEST_PASSWORD)
# Create some sample notifications for the user
Notification.objects.create(user=self.user, app_name='App Name 1', notification_type='Type A')
@@ -580,7 +588,7 @@ class MarkNotificationsSeenAPIViewTestCase(APITestCase):
# Create a POST request to mark notifications as seen for 'App Name 1'
app_name = 'App Name 1'
url = reverse('mark-notifications-seen', kwargs={'app_name': app_name})
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.put(url)
# Assert the response status code is 200 (OK)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@@ -600,9 +608,10 @@ class NotificationReadAPIViewTestCase(APITestCase):
"""
def setUp(self):
- self.user = UserFactory()
+ self.TEST_PASSWORD = 'Password1234'
+ self.user = UserFactory(password=self.TEST_PASSWORD)
self.url = reverse('notifications-read')
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
# Create some sample notifications for the user with already existing apps and with invalid app name
Notification.objects.create(user=self.user, app_name='app_name_2', notification_type='Type A')
@@ -659,7 +668,7 @@ class NotificationReadAPIViewTestCase(APITestCase):
# Create a PATCH request to mark notification as read for notification_id: 2 through a different user
self.client.logout()
self.user = UserFactory()
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
notification_id = 2
data = {'notification_id': notification_id}
diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py
index 4ee3a614a7..39bf8755da 100644
--- a/openedx/core/djangoapps/notifications/utils.py
+++ b/openedx/core/djangoapps/notifications/utils.py
@@ -42,3 +42,12 @@ def get_show_notifications_tray(user):
break
return show_notifications_tray
+
+
+def get_list_in_batches(input_list, batch_size):
+ """
+ Divides the list of objects into list of list of objects each of length batch_size.
+ """
+ list_length = len(input_list)
+ for index in range(0, list_length, batch_size):
+ yield input_list[index: index + batch_size]
diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst
index d65aaaf4a9..d859040516 100644
--- a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst
+++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst
@@ -63,7 +63,7 @@ The code examples below show this in action.
Remove JWT_ISSUERS
~~~~~~~~~~~~~~~~~~
-edx_rest_framework_extensions.settings_ supports having a list of **JWT_ISSUERS** instead of just a single
+`edx_rest_framework_extensions.settings`_ supports having a list of **JWT_ISSUERS** instead of just a single
one. This support for configuring multiple issuers is present across many services. However, this does not
conform to the `JWT standard`_, where the `issuer`_ is intended to identify the entity that generates and
signs the JWT. In our case, that should be the single Auth service only.
@@ -81,70 +81,56 @@ issuer, but with (the potential of) multiple signing keys stored in a JWT Set.
.. _JSON Web Key Set (JWK Set): https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36#section-5
.. _site configuration: https://github.com/openedx/edx-platform/blob/af841336c7e39d634c238cd8a11c5a3a661aa9e2/openedx/core/djangoapps/site_configuration/__init__.py
-Example Code
-------------
+Features
+--------
KeyPair Generation
~~~~~~~~~~~~~~~~~~
-Here is code for generating a keypair::
+Please have a look at ``openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py``
+to get better understanding how to generate keypair using ``PyJWT``.
- from Cryptodome.PublicKey import RSA
- from jwkest import jwk
+The public and private keypair would be similar to the following::
- rsa_key = RSA.generate(2048)
- rsa_jwk = jwk.RSAKey(kid="your_key_id", key=rsa_key)
+ ## Public keyset
+ """
+ {
+ "keys": [
+ {
+ "kty": "RSA",
+ "key_ops": ["verify"],
+ "n": "...",
+ "e": "...",
+ "kid": "your_key_id"
+ }
+ ]
+ }
+ """
-To serialize the **public key** in a `JSON Web Key Set (JWK Set)`_::
- public_keys = jwk.KEYS()
- public_keys.append(rsa_jwk)
- serialized_public_keys_json = public_keys.dump_jwks()
-
-and its sample output::
-
- {
- "keys": [
- {
- "kid": "your_key_id",
- "e": "strawberry",
- "kty": "RSA",
- "n": "something"
- }
- ]
- }
-
-To serialize the **keypair** as a JWK::
-
- serialized_keypair = rsa_jwk.serialize(private=True)
- serialized_keypair_json = json.dumps(serialized_keypair)
-
-and its sample output::
-
- {
- "e": "strawberry",
- "d": "apple",
- "n": "banana",
- "q": "pear",
- "p": "plum",
- "kid": "your_key_id",
- "kty": "RSA"
- }
+ ## Private key
+ """
+ {
+ "kty": "RSA",
+ "key_ops": ["sign"],
+ "n": "...",
+ "e": "...",
+ "d": "...",
+ "p": "...",
+ "q": "...",
+ "dp": "...",
+ "dq": "...",
+ "qi": "...",
+ "kid": "your_key_id"
+ }
+ """
Signing
~~~~~~~
-To deserialize the keypair from above::
+To create a signature you simply need a **payload**, **private key** and your hashing algorithm::
- private_keys = jwk.KEYS()
- serialized_keypair = json.loads(serialized_keypair_json)
- private_keys.add(serialized_keypair)
-
-To create a signature::
-
- from jwkest.jws import JWS
- jws = JWS("JWT payload", alg="RS512")
- signed_message = jws.sign_compact(keys=private_keys)
+ signed_message = jwt.encode("JWT payload in dict format", key=private_key, algorithm="RS512")
Note: we specify **RS512** above to identify *RSASSA-PKCS1-v1_5 using SHA-512* as
the signature algorithm value as described in the `JSON Web Algorithms (JWA)`_ spec.
@@ -154,24 +140,20 @@ the signature algorithm value as described in the `JSON Web Algorithms (JWA)`_ s
Verify Signature
~~~~~~~~~~~~~~~~
-To verify the signature from above::
+To verify the signature we'll be looping through the public keys and try to verify the signature with each of them.
+For more details you can have a look at `verify_jwk_signature_using_keyset`_. To generate ``keyset`` required for verification you
+can use `get_verification_jwk_key_set`_ method.
- public_keys = jwk.KEYS()
- public_keys.load_jwks(serialized_public_keys_json)
- jws.verify_compact(signed_message, public_keys)
+.. _verify_jwk_signature_using_keyset: https://github.com/openedx/edx-drf-extensions/blob/master/edx_rest_framework_extensions/auth/jwt/decoder.py#L270
+.. _get_verification_jwk_key_set : https://github.com/openedx/edx-drf-extensions/blob/master/edx_rest_framework_extensions/auth/jwt/decoder.py#L395
Key Rotation
~~~~~~~~~~~~
-When a new public key is added in the future, it should have a unique "kid"
-value and added to the public keys JWK set::
-
- new_rsa_key = RSA.generate(2048)
- new_rsa_jwk = jwk.RSAKey(kid="new_id", key=new_rsa_key)
- public_keys.append(new_rsa_jwk)
-
-When a JWS is created, it is signed with a certain "kid"-identified keypair. When it
-is later verified, the public key with the matching "kid" in the JWK set is used.
+In future if we plan to rotate the keys, we can simply add new key public key to the public keyset and remove the old private one.
+Means, at any time there might be more than one public key but there will be only one private key. Considering that we are doing verification
+by looping through all the available public keys, the ``kid`` parameter is not
+as important as it was before. But it's still recommended to use it. It will help us to differentiate between the old and new public keys.
Consequences
------------
diff --git a/openedx/core/djangoapps/oauth_dispatch/jwt.py b/openedx/core/djangoapps/oauth_dispatch/jwt.py
index 329d28822f..1d88e835bc 100644
--- a/openedx/core/djangoapps/oauth_dispatch/jwt.py
+++ b/openedx/core/djangoapps/oauth_dispatch/jwt.py
@@ -5,12 +5,13 @@ import json
import logging
from time import time
+import jwt
from django.conf import settings
from edx_django_utils.monitoring import increment, set_custom_attribute
from edx_rbac.utils import create_role_auth_claim_for_user
from edx_toggles.toggles import SettingToggle
-from jwkest import jwk
-from jwkest.jws import JWS
+from jwt import PyJWK
+from jwt.utils import base64url_encode
from common.djangoapps.student.models import UserProfile, anonymous_id_for_user
@@ -273,17 +274,14 @@ def _attach_profile_claim(payload, user):
def _encode_and_sign(payload, use_asymmetric_key, secret):
"""Encode and sign the provided payload."""
- keys = jwk.KEYS()
if use_asymmetric_key:
- serialized_keypair = json.loads(settings.JWT_AUTH['JWT_PRIVATE_SIGNING_JWK'])
- keys.add(serialized_keypair)
+ key = json.loads(settings.JWT_AUTH['JWT_PRIVATE_SIGNING_JWK'])
algorithm = settings.JWT_AUTH['JWT_SIGNING_ALGORITHM']
else:
- key = secret if secret else settings.JWT_AUTH['JWT_SECRET_KEY']
- keys.add({'key': key, 'kty': 'oct'})
+ secret = secret if secret else settings.JWT_AUTH['JWT_SECRET_KEY']
+ key = {'k': base64url_encode(secret.encode('utf-8')), 'kty': 'oct'}
algorithm = settings.JWT_AUTH['JWT_ALGORITHM']
- data = json.dumps(payload)
- jws = JWS(data, alg=algorithm)
- return jws.sign_compact(keys=keys)
+ jwk = PyJWK(key, algorithm)
+ return jwt.encode(payload, jwk.key, algorithm=algorithm)
diff --git a/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py b/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py
index cca094bf82..a139cebca1 100644
--- a/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py
+++ b/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py
@@ -14,7 +14,7 @@ import yaml
from Cryptodome.PublicKey import RSA
from django.conf import settings
from django.core.management.base import BaseCommand
-from jwkest import jwk
+from jwt.algorithms import get_default_algorithms
log = logging.getLogger(__name__)
@@ -123,15 +123,23 @@ class Command(BaseCommand):
def _generate_key_pair(self, key_size, key_id):
log.info('Generating new JWT signing keypair for key id %s.', key_id)
rsa_key = RSA.generate(key_size)
- rsa_jwk = jwk.RSAKey(kid=key_id, key=rsa_key)
- return rsa_jwk
+ algo = get_default_algorithms()['RS512']
+ key_data = algo.prepare_key(rsa_key.export_key('PEM').decode())
+ rsa_jwk = json.loads(algo.to_jwk(key_data))
+ public_rsa_jwk = json.loads(algo.to_jwk(key_data.public_key()))
+
+ rsa_jwk['kid'] = key_id
+ public_rsa_jwk['kid'] = key_id
+ return {'private': rsa_jwk, 'public': public_rsa_jwk}
def _output_public_keys(self, jwk_key, add_previous, strip_prefix):
- public_keys = jwk.KEYS()
+ public_keys = {'keys': []}
+
if add_previous:
self._add_previous_public_keys(public_keys)
- public_keys.append(jwk_key)
- serialized_public_keys = public_keys.dump_jwks()
+
+ public_keys['keys'].append(jwk_key['public'])
+ serialized_public_keys = json.dumps(public_keys)
prefix = '' if strip_prefix else 'COMMON_'
public_signing_key = f'{prefix}JWT_PUBLIC_SIGNING_JWK_SET'
@@ -155,11 +163,10 @@ class Command(BaseCommand):
previous_signing_keys = settings.JWT_AUTH.get('JWT_PUBLIC_SIGNING_JWK_SET')
if previous_signing_keys:
log.info('Old JWT_PUBLIC_SIGNING_JWK_SET: %s.', previous_signing_keys)
- public_keys.load_jwks(previous_signing_keys)
+ public_keys['keys'].extend(json.loads(previous_signing_keys)['keys'])
def _output_private_keys(self, jwk_key, strip_prefix):
- serialized_keypair = jwk_key.serialize(private=True)
- serialized_keypair_json = json.dumps(serialized_keypair)
+ serialized_keypair_json = json.dumps(jwk_key['private'])
prefix = '' if strip_prefix else 'EDXAPP_'
private_signing_key = f'{prefix}JWT_PRIVATE_SIGNING_JWK'
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
index 6818c171c6..d99ac4883b 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py
@@ -3,10 +3,11 @@ OAuth Dispatch test mixins
"""
import pytest
-import jwt
from django.conf import settings
-from jwkest.jwk import KEYS
-from jwkest.jws import JWS
+from edx_rest_framework_extensions.auth.jwt.decoder import (
+ get_verification_jwk_key_set,
+ verify_jwk_signature_using_keyset
+)
from jwt.exceptions import ExpiredSignatureError
from common.djangoapps.student.models import UserProfile, anonymous_id_for_user
@@ -33,25 +34,15 @@ class AccessTokenMixin:
Helper method to decode a JWT with the ability to
verify the expiration of said token
"""
- keys = KEYS()
- if should_be_asymmetric_key:
- keys.load_jwks(settings.JWT_AUTH['JWT_PUBLIC_SIGNING_JWK_SET'])
- else:
- keys.add({'key': secret_key, 'kty': 'oct'})
+ asymmetric_keys = settings.JWT_AUTH.get('JWT_PUBLIC_SIGNING_JWK_SET') if should_be_asymmetric_key else None
+ key_set = get_verification_jwk_key_set(asymmetric_keys=asymmetric_keys, secret_key=secret_key)
+ data = verify_jwk_signature_using_keyset(access_token,
+ key_set,
+ iss=issuer,
+ aud=aud,
+ verify_exp=verify_expiration)
- _ = JWS().verify_compact(access_token.encode('utf-8'), keys)
-
- return jwt.decode(
- access_token,
- secret_key,
- algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']],
- audience=audience,
- issuer=issuer,
- options={
- 'verify_signature': False,
- "verify_exp": verify_expiration
- },
- )
+ return data
# Note that if we expect the claims to have expired
# then we ask the JWT library not to verify expiration
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py
index 37bc12ceaa..dc38b18c6e 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py
@@ -31,19 +31,20 @@ class AuthenticateTestCase(TestCase):
def setUp(self):
super().setUp()
+ self.TEST_PASSWORD = 'Password1234'
self.user = UserFactory.create(
username='darkhelmet',
- password='12345',
+ password=self.TEST_PASSWORD,
email='darkhelmet@spaceball_one.org',
)
self.validator = EdxOAuth2Validator()
def test_authenticate_with_username(self):
- user = self.validator._authenticate(username='darkhelmet', password='12345')
+ user = self.validator._authenticate(username='darkhelmet', password=self.TEST_PASSWORD)
assert self.user == user
def test_authenticate_with_email(self):
- user = self.validator._authenticate(username='darkhelmet@spaceball_one.org', password='12345')
+ user = self.validator._authenticate(username='darkhelmet@spaceball_one.org', password=self.TEST_PASSWORD)
assert self.user == user
@@ -56,9 +57,10 @@ class CustomValidationTestCase(TestCase):
"""
def setUp(self):
super().setUp()
+ self.TEST_PASSWORD = 'Password1234'
self.user = UserFactory.create(
username='darkhelmet',
- password='12345',
+ password=self.TEST_PASSWORD,
email='darkhelmet@spaceball_one.org',
)
self.validator = EdxOAuth2Validator()
@@ -67,13 +69,13 @@ class CustomValidationTestCase(TestCase):
def test_active_user_validates(self):
assert self.user.is_active
request = self.request_factory.get('/')
- assert self.validator.validate_user('darkhelmet', '12345', client=None, request=request)
+ assert self.validator.validate_user('darkhelmet', self.TEST_PASSWORD, client=None, request=request)
def test_inactive_user_validates(self):
self.user.is_active = False
self.user.save()
request = self.request_factory.get('/')
- assert self.validator.validate_user('darkhelmet', '12345', client=None, request=request)
+ assert self.validator.validate_user('darkhelmet', self.TEST_PASSWORD, client=None, request=request)
@skip_unless_lms
@@ -87,9 +89,10 @@ class CustomAuthorizationViewTestCase(TestCase):
"""
def setUp(self):
super().setUp()
+ self.TEST_PASSWORD = 'Password1234'
self.dot_adapter = adapters.DOTAdapter()
- self.user = UserFactory()
- self.client.login(username=self.user.username, password='test')
+ self.user = UserFactory(password=self.TEST_PASSWORD)
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.restricted_dot_app = self._create_restricted_app()
self._create_expired_token(self.restricted_dot_app)
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
index d6ed9ca30c..7bd991ba60 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
@@ -9,12 +9,10 @@ from unittest.mock import call, patch
import ddt
import httpretty
-from Cryptodome.PublicKey import RSA
from django.conf import settings
from django.test import RequestFactory, TestCase
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_switch
-from jwkest import jwk
from oauth2_provider import models as dot_models
from common.djangoapps.student.tests.factories import UserFactory
@@ -86,8 +84,9 @@ class _DispatchingViewTestCase(TestCase):
"""
def setUp(self):
super().setUp()
+ self.TEST_PASSWORD = 'Password1234'
self.dot_adapter = adapters.DOTAdapter()
- self.user = UserFactory()
+ self.user = UserFactory(password=self.TEST_PASSWORD)
self.dot_app = self.dot_adapter.create_public_client(
name='test dot application',
user=self.user,
@@ -148,7 +147,7 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
if grant_type == dot_models.Application.GRANT_PASSWORD:
body['username'] = user.username
- body['password'] = 'test'
+ body['password'] = self.TEST_PASSWORD
elif grant_type == dot_models.Application.GRANT_CLIENT_CREDENTIALS:
body['client_secret'] = client.client_secret
@@ -163,20 +162,6 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa
return body
- def _generate_key_pair(self):
- """ Generates an asymmetric key pair and returns the JWK of its public keys and keypair. """
- rsa_key = RSA.generate(2048)
- rsa_jwk = jwk.RSAKey(kid="key_id", key=rsa_key)
-
- public_keys = jwk.KEYS()
- public_keys.append(rsa_jwk)
- serialized_public_keys_json = public_keys.dump_jwks()
-
- serialized_keypair = rsa_jwk.serialize(private=True)
- serialized_keypair_json = json.dumps(serialized_keypair)
-
- return serialized_public_keys_json, serialized_keypair_json
-
def _test_jwt_access_token(self, client_attr, token_type=None, headers=None, grant_type=None, asymmetric_jwt=False):
"""
Test response for JWT token.
@@ -598,7 +583,7 @@ class TestAuthorizationView(_DispatchingViewTestCase):
@ddt.unpack
def test_post_authorization_view(self, client_type, allow_field):
oauth_application = getattr(self, f'{client_type}_app')
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.post(
'/oauth2/authorize/',
{
@@ -620,7 +605,7 @@ class TestAuthorizationView(_DispatchingViewTestCase):
Make sure we get the overridden Authorization page - not
the default django-oauth-toolkit when we perform a page load
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
response = self.client.get(
'/oauth2/authorize/',
{
@@ -787,7 +772,7 @@ class TestRevokeTokenView(AccessTokenLoginMixin, _DispatchingViewTestCase): # p
'client_id': self.dot_app.client_id,
'grant_type': 'password',
'username': self.user.username,
- 'password': 'test',
+ 'password': self.TEST_PASSWORD,
}
def access_token_post_body_with_refresh_token(self, refresh_token):
diff --git a/openedx/core/djangoapps/safe_sessions/middleware.py b/openedx/core/djangoapps/safe_sessions/middleware.py
index a9ade2ac3b..5f4449d93c 100644
--- a/openedx/core/djangoapps/safe_sessions/middleware.py
+++ b/openedx/core/djangoapps/safe_sessions/middleware.py
@@ -267,10 +267,7 @@ class SafeCookieData:
SHA256(version '|' session_id '|' user_id '|').
"""
data_to_sign = self._compute_digest(user_id)
-
- self.signature = signing.TimestampSigner(
- salt=self.key_salt, algorithm=settings.DEFAULT_HASHING_ALGORITHM
- ).sign_object(data_to_sign, serializer=signing.JSONSerializer, compress=False)
+ self.signature = signing.dumps(data_to_sign, salt=self.key_salt)
def verify(self, user_id):
"""
@@ -279,10 +276,7 @@ class SafeCookieData:
(not expired) and bound to the given user.
"""
try:
- unsigned_data = signing.TimestampSigner(
- salt=self.key_salt, algorithm=settings.DEFAULT_HASHING_ALGORITHM
- ).unsign_object(self.signature, serializer=signing.JSONSerializer, max_age=settings.SESSION_COOKIE_AGE)
-
+ unsigned_data = signing.loads(self.signature, salt=self.key_salt, max_age=settings.SESSION_COOKIE_AGE)
if unsigned_data == self._compute_digest(user_id):
return True
log.error("SafeCookieData '%r' is not bound to user '%s'.", str(self), user_id)
diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py
index 145233c9c0..079cdcdd0c 100644
--- a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py
+++ b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py
@@ -36,6 +36,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase):
self.user = UserFactory.create()
self.addCleanup(set_current_request, None)
self.request = get_mock_request()
+ self.TEST_PASSWORD = 'Password1234'
def assert_response(self, safe_cookie_data=None, success=True):
"""
@@ -79,7 +80,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase):
@patch("openedx.core.djangoapps.safe_sessions.middleware.LOG_REQUEST_USER_CHANGES", False)
@patch("openedx.core.djangoapps.safe_sessions.middleware.track_request_user_changes")
def test_success(self, mock_log_request_user_changes):
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
session_id = self.client.session.session_key
safe_cookie_data = SafeCookieData.create(session_id, self.user.id)
@@ -106,7 +107,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase):
@patch("openedx.core.djangoapps.safe_sessions.middleware.LOG_REQUEST_USER_CHANGES", True)
@patch("openedx.core.djangoapps.safe_sessions.middleware.track_request_user_changes")
def test_log_request_user_on(self, mock_log_request_user_changes):
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
session_id = self.client.session.session_key
safe_cookie_data = SafeCookieData.create(session_id, self.user.id)
@@ -134,7 +135,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase):
self.assert_no_session()
def test_invalid_user_at_step_4(self):
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
safe_cookie_data = SafeCookieData.create(self.client.session.session_key, 'no_such_user')
self.request.META['HTTP_ACCEPT'] = 'text/html'
with self.assert_incorrect_user_logged():
@@ -225,6 +226,16 @@ class TestSafeSessionProcessResponse(TestSafeSessionsLogMixin, TestCase):
assert safe_cookie_data.session_id == 'some_session_id'
assert safe_cookie_data.verify(self.user.id)
+ def test_update_cookie_data_at_step_3_with_sha256(self):
+ """ first encode cookie with default algo sha1 and then check with sha256"""
+ self.assert_response(set_request_user=True, set_session_cookie=True)
+ serialized_cookie_data = self.client.response.cookies[settings.SESSION_COOKIE_NAME].value
+ safe_cookie_data = SafeCookieData.parse(serialized_cookie_data)
+ assert safe_cookie_data.version == SafeCookieData.CURRENT_VERSION
+ assert safe_cookie_data.session_id == 'some_session_id'
+ with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'):
+ assert safe_cookie_data.verify(self.user.id)
+
def test_cant_update_cookie_at_step_3_error(self):
self.client.response.cookies[settings.SESSION_COOKIE_NAME] = None
with self.assert_invalid_session_id():
@@ -250,7 +261,8 @@ class TestSafeSessionMiddleware(TestSafeSessionsLogMixin, CacheIsolationTestCase
def setUp(self):
super().setUp()
- self.user = UserFactory.create()
+ self.TEST_PASSWORD = 'Password1234'
+ self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.addCleanup(set_current_request, None)
self.request = get_mock_request()
self.client.response = HttpResponse()
@@ -270,7 +282,7 @@ class TestSafeSessionMiddleware(TestSafeSessionsLogMixin, CacheIsolationTestCase
"""
Set up request for success path -- everything up until process_response().
"""
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
session_id = self.client.session.session_key
safe_cookie_data = SafeCookieData.create(session_id, self.user.id)
diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py
index b8a23c567d..47efa626ec 100644
--- a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py
+++ b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py
@@ -10,6 +10,7 @@ from unittest.mock import patch
import pytest
import ddt
+import django
from django.test import TestCase
from ..middleware import SafeCookieData, SafeCookieError
@@ -204,6 +205,7 @@ class TestSafeCookieData(TestSafeSessionsLogMixin, TestCase):
#---- Test roundtrip with pinned values ----#
+ @pytest.mark.skipif(django.VERSION[0] >= 4, reason="For django32 default algorithm is sha1. No need for django42.")
def test_pinned_values(self):
"""
Compute a cookie with all inputs held constant and assert that the
@@ -236,3 +238,37 @@ class TestSafeCookieData(TestSafeSessionsLogMixin, TestCase):
":1m6Hve"
":OMhY2FL2pudJjSSXChtI-zR8QVA"
)
+
+ @pytest.mark.skipif(django.VERSION[0] < 4, reason="For django42 default algorithm is sha256. No need for django32.")
+ def test_pinned_values_django42(self):
+ """
+ Compute a cookie with all inputs held constant and assert that the
+ exact output never changes. This protects against unintentional
+ changes to the algorithm.
+ """
+ user_id = '8523'
+ session_id = 'SSdtIGEgc2Vzc2lvbiE'
+ a_random_string = 'HvGnjXf1b3jU'
+ timestamp = 1626895850
+
+ module = 'openedx.core.djangoapps.safe_sessions.middleware'
+ with patch(f"{module}.signing.time.time", return_value=timestamp):
+ with patch(f"{module}.get_random_string", return_value=a_random_string):
+ safe_cookie_data = SafeCookieData.create(session_id, user_id)
+ serialized_value = str(safe_cookie_data)
+
+ # **IMPORTANT**: If a change to the algorithm causes this test
+ # to start failing, you will either need to allow both the old
+ # and new format or all users will become logged out upon
+ # deploy of the changes.
+ #
+ # Also assumes SECRET_KEY is '85920908f28904ed733fe576320db18cabd7b6cd'
+ # (set in lms or cms.envs.test)
+ assert serialized_value == (
+ "1"
+ "|SSdtIGEgc2Vzc2lvbiE"
+ "|HvGnjXf1b3jU"
+ "|ImExZWZiNzVlZGFmM2FkZWZmYjM4YjI0ZmZkOWU4MzExODU0MTk4NmVlNGRiYzBlODdhYWUzOGM5MzVlNzk4NjUi"
+ ":1m6Hve"
+ ":Pra4iochviPvKUoIV33gdVZFDgG-cMDlIYfl8iFIMaY"
+ )
diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py
index d11caaa24d..ca693b4d10 100644
--- a/openedx/core/djangoapps/signals/signals.py
+++ b/openedx/core/djangoapps/signals/signals.py
@@ -11,7 +11,7 @@ COURSE_GRADE_CHANGED = Signal()
# Signal that fires when a user is awarded a certificate in a course (in the certificates django app)
# TODO: runtime coupling between apps will be reduced if this event is changed to carry a username
-# rather than a User object; however, this will require changes to the milestones and badges APIs
+# rather than a User object; however, this will require changes to the milestones
# Same providing_args=["user", "course_key", "mode", "status"] for next 3 signals.
COURSE_CERT_CHANGED = Signal()
COURSE_CERT_AWARDED = Signal()
diff --git a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
index 21b9ba952a..d7578f25eb 100644
--- a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
+++ b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py
@@ -24,14 +24,15 @@ class TestComprehensiveThemeLMS(TestCase):
Clear static file finders cache and register cleanup methods.
"""
super().setUp()
- self.user = UserFactory()
+ self.TEST_PASSWORD = 'Password1234'
+ self.user = UserFactory(password=self.TEST_PASSWORD)
# Clear the internal staticfiles caches, to get test isolation.
staticfiles.finders.get_finder.cache_clear()
def _login(self):
""" Log into LMS. """
- self.client.login(username=self.user.username, password='test')
+ self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
@with_comprehensive_theme("test-theme")
def test_footer(self):
diff --git a/openedx/core/djangoapps/theming/tests/test_views.py b/openedx/core/djangoapps/theming/tests/test_views.py
index 3eefd7bed8..5b5503702c 100644
--- a/openedx/core/djangoapps/theming/tests/test_views.py
+++ b/openedx/core/djangoapps/theming/tests/test_views.py
@@ -13,7 +13,7 @@ from openedx.core.djangoapps.theming.models import SiteTheme
THEMING_ADMIN_URL = '/theming/admin'
TEST_THEME_NAME = 'test-theme'
-TEST_PASSWORD = 'test'
+TEST_PASSWORD = 'Password1234'
class TestThemingViews(TestCase):
diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py
index 4e1c6b426d..bc4fd1d1a7 100644
--- a/openedx/core/djangoapps/user_api/accounts/api.py
+++ b/openedx/core/djangoapps/user_api/accounts/api.py
@@ -404,9 +404,23 @@ def get_name_validation_error(name):
:return: Validation error message.
"""
+
+ def contains_html(value):
+ """
+ Validator method to check whether name contains html tags
+ """
+ regex = re.compile('(<|>)', re.UNICODE)
+ return bool(regex.search(value))
+
+ def contains_url(value):
+ """
+ Validator method to check whether full name contains url
+ """
+ regex = re.findall(r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))*', value)
+ return bool(regex)
+
if name:
- regex = re.findall(r'https|http?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+', name)
- return _('Enter a valid name') if bool(regex) else ''
+ return _('Enter a valid name') if (contains_html(name) or contains_url(name)) else ''
else:
return accounts.REQUIRED_FIELD_NAME_MSG
diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py
index 75ecfa35bf..b17f479b45 100644
--- a/openedx/core/djangoapps/user_api/accounts/serializers.py
+++ b/openedx/core/djangoapps/user_api/accounts/serializers.py
@@ -21,7 +21,6 @@ from common.djangoapps.student.models import (
UserPasswordToggleHistory,
UserProfile
)
-from lms.djangoapps.badges.utils import badges_enabled
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.user_api import errors
from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled
@@ -136,7 +135,6 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d
except ObjectDoesNotExist:
activation_key = None
- accomplishments_shared = badges_enabled()
data = {
"username": user.username,
"url": self.context.get('request').build_absolute_uri(
@@ -164,7 +162,6 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d
"level_of_education": None,
"mailing_address": None,
"requires_parental_consent": None,
- "accomplishments_shared": accomplishments_shared,
"account_privacy": self.configuration.get('default_visibility'),
"social_links": None,
"extended_profile_fields": None,
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
index fb4686d139..569ec98466 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py
@@ -88,7 +88,7 @@ class TestAccountApi(UserSettingsEventTestMixin, EmailTemplateTagMixin, CreateAc
This includes the specific types of error raised, and default behavior when optional arguments
are not specified.
"""
- password = "test"
+ password = 'Password1234'
def setUp(self):
super().setUp()
@@ -625,7 +625,6 @@ class AccountSettingsOnCreationTest(CreateAccountMixin, TestCase):
'requires_parental_consent': True,
'language_proficiencies': [],
'account_privacy': PRIVATE_VISIBILITY,
- 'accomplishments_shared': False,
'extended_profile': [],
'secondary_email': None,
'secondary_email_enabled': None,
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py
index 6ec6e3694f..0cc2754e47 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py
@@ -39,7 +39,7 @@ from openedx.core.djangoapps.credit.models import (
CreditRequirement,
CreditRequirementStatus
)
-from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
+from openedx.core.djangoapps.external_user_ids.models import ExternalIdType
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView
@@ -496,7 +496,6 @@ class TestPartnerReportingList(ModuleStoreTestCase):
"""
EXPECTED_MB_ORGS_CONFIG = [
{
- AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching',
AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [
AccountRetirementPartnerReportView.STUDENT_ID_KEY,
AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY,
@@ -516,7 +515,7 @@ class TestPartnerReportingList(ModuleStoreTestCase):
self.url = reverse('accounts_retirement_partner_report')
self.maxDiff = None
self.test_created_datetime = datetime.datetime(2018, 1, 1, tzinfo=pytz.UTC)
- ExternalIdType.objects.get_or_create(name=ExternalIdType.MICROBACHELORS_COACHING)
+ ExternalIdType.objects.get_or_create(name=ExternalIdType.CALIPER)
def get_user_dict(self, user, enrollments):
"""
@@ -624,56 +623,6 @@ class TestPartnerReportingList(ModuleStoreTestCase):
self.assert_status_and_user_list(user_dicts)
- def test_success_mb_coaching(self):
- """
- Check that MicroBachelors users who have consented to coaching have the proper info
- included for the partner report.
- """
- path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching'
- with mock.patch(path, return_value=True) as mock_has_ever_consented:
- user_dicts, users = self.create_partner_reporting_statuses(num=1)
- external_id, created = ExternalId.add_new_user_id( # lint-amnesty, pylint: disable=unused-variable
- user=users[0],
- type_name=ExternalIdType.MICROBACHELORS_COACHING
- )
-
- expected_user = user_dicts[0]
- expected_users = [expected_user]
- expected_user[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = str(external_id.external_user_id)
- expected_user[
- AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = TestPartnerReportingList.EXPECTED_MB_ORGS_CONFIG
-
- self.assert_status_and_user_list(expected_users)
- mock_has_ever_consented.assert_called_once()
-
- def test_success_mb_coaching_no_external_id(self):
- """
- Check that MicroBachelors users who have consented to coaching, but who do not have an external id, have the
- proper info included for the partner report.
- """
- path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching'
- with mock.patch(path, return_value=True) as mock_has_ever_consented:
- user_dicts, users = self.create_partner_reporting_statuses(num=1) # lint-amnesty, pylint: disable=unused-variable
-
- self.assert_status_and_user_list(user_dicts)
- mock_has_ever_consented.assert_called_once()
-
- def test_success_mb_coaching_no_consent(self):
- """
- Check that MicroBachelors users who have not consented to coaching have the proper info
- included for the partner report.
- """
- path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching'
- with mock.patch(path, return_value=False) as mock_has_ever_consented:
- user_dicts, users = self.create_partner_reporting_statuses(num=1)
- ExternalId.add_new_user_id(
- user=users[0],
- type_name=ExternalIdType.MICROBACHELORS_COACHING
- )
-
- self.assert_status_and_user_list(user_dicts)
- mock_has_ever_consented.assert_called_once()
-
def test_no_users(self):
"""
Checks that the call returns a success code and empty list if no users are found.
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
index 63cb59cace..546b8afacc 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py
@@ -359,7 +359,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
ENABLED_CACHES = ['default']
TOTAL_QUERY_COUNT = 24
- FULL_RESPONSE_FIELD_COUNT = 30
+ FULL_RESPONSE_FIELD_COUNT = 29
def setUp(self):
super().setUp()
@@ -379,12 +379,12 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
legacy_profile.save()
return year_of_birth
- def _verify_full_shareable_account_response(self, response, account_privacy=None, badges_enabled=False):
+ def _verify_full_shareable_account_response(self, response, account_privacy=None):
"""
Verify that the shareable fields from the account are returned
"""
data = response.data
- assert 12 == len(data)
+ assert 11 == len(data)
# public fields (3)
assert account_privacy == data['account_privacy']
@@ -399,7 +399,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
assert 'm' == data['level_of_education']
assert data['social_links'] is not None
assert data['time_zone'] is None
- assert badges_enabled == data['accomplishments_shared']
def _verify_private_account_response(self, response, requires_parental_consent=False):
"""
@@ -436,7 +435,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
assert 'm' == data['level_of_education']
assert data['social_links'] is not None
assert UserPreference.get_value(self.user, 'time_zone') == data['time_zone']
- assert data['accomplishments_shared'] is not None
assert ((self.user.first_name + ' ') + self.user.last_name) == data['name']
# additional admin fields (13)
@@ -669,7 +667,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
response = self.send_get(self.different_client)
self._verify_private_account_response(response)
- @mock.patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True})
@ddt.data(
("client", "user", PRIVATE_VISIBILITY),
("different_client", "different_user", PRIVATE_VISIBILITY),
@@ -691,7 +688,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
if preference_visibility == PRIVATE_VISIBILITY:
self._verify_private_account_response(response)
else:
- self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY, badges_enabled=True)
+ self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY)
client = self.login_client(api_client, requesting_username)
@@ -812,8 +809,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe
assert [] == data['language_proficiencies']
assert PRIVATE_VISIBILITY == data['account_privacy']
assert data['time_zone'] is None
- # Badges aren't on by default, so should not be present.
- assert data['accomplishments_shared'] is False
self.client.login(username=self.user.username, password=TEST_PASSWORD)
verify_get_own_information(self._get_num_queries(22))
diff --git a/openedx/core/djangoapps/user_api/accounts/tests/testutils.py b/openedx/core/djangoapps/user_api/accounts/tests/testutils.py
index 8befbbdc90..24f7c16f24 100644
--- a/openedx/core/djangoapps/user_api/accounts/tests/testutils.py
+++ b/openedx/core/djangoapps/user_api/accounts/tests/testutils.py
@@ -9,7 +9,12 @@ from common.djangoapps.util.password_policy_validators import DEFAULT_MAX_PASSWO
INVALID_NAMES = [
None,
'',
- ''
+ 'http://',
+ 'https://',
+ '',
+ 'https://www.example.com',
+ 'Valid name http://www.example.com',
+ 'Valid name ',
]
INVALID_USERNAMES_ASCII = [
diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py
index 83bdde4f44..6eb2e8341e 100644
--- a/openedx/core/djangoapps/user_api/accounts/views.py
+++ b/openedx/core/djangoapps/user_api/accounts/views.py
@@ -57,7 +57,6 @@ from openedx.core.djangoapps.ace_common.template_context import get_base_templat
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments
from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus
-from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.profile_images.images import remove_profile_images
from openedx.core.djangoapps.user_api import accounts
@@ -93,11 +92,6 @@ from .serializers import (
from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS
from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator
-try:
- from coaching.api import has_ever_consented_to_coaching
-except ImportError:
- has_ever_consented_to_coaching = None
-
log = logging.getLogger(__name__)
USER_PROFILE_PII = {
@@ -250,9 +244,6 @@ class AccountViewSet(ViewSet):
If "custom", the user has selectively chosen a subset of shareable
fields to make visible to others via the User Preferences API.
- * accomplishments_shared: Signals whether badges are enabled on the
- platform and should be fetched.
-
* phone_number: The phone number for the user. String of numbers with
an optional `+` sign at the start.
@@ -746,49 +737,8 @@ class AccountRetirementPartnerReportView(ViewSet):
'created': retirement_status.created,
}
- # Some orgs have a custom list of headings and content for the partner report. Add this, if applicable.
- self._add_orgs_config_for_user(retirement, retirement_status.user)
-
return retirement
- def _add_orgs_config_for_user(self, retirement, user):
- """
- Check to see if the user's info was sent to any partners (orgs) that have a a custom list of headings and
- content for the partner report. If so, add this.
- """
- # See if the MicroBachelors coaching provider needs to be notified of this user's retirement
- if has_ever_consented_to_coaching is not None and has_ever_consented_to_coaching(user):
- # See if the user has a MicroBachelors external id. If not, they were never sent to the
- # coaching provider.
- external_ids = ExternalId.objects.filter(
- user=user,
- external_id_type__name=ExternalIdType.MICROBACHELORS_COACHING
- )
- if external_ids.exists():
- # User has an external id. Add the additional info.
- external_id = str(external_ids[0].external_user_id)
- self._add_coaching_orgs_config(retirement, external_id)
-
- def _add_coaching_orgs_config(self, retirement, external_id):
- """
- Add the orgs configuration for MicroBachelors coaching
- """
- # Add the custom field headings
- retirement[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = [
- {
- AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching',
- AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [
- AccountRetirementPartnerReportView.STUDENT_ID_KEY,
- AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY,
- AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY,
- AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY
- ]
- }
- ]
-
- # Add the custom field value
- retirement[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = external_id
-
@request_requires_username
def retirement_partner_status_create(self, request):
"""
diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py
index aaf2daf96f..28b4c57505 100644
--- a/openedx/core/djangoapps/user_api/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/tests/test_views.py
@@ -33,6 +33,7 @@ class UserAPITestCase(ApiTestCase):
Parent test case for User API workflow coverage
"""
LIST_URI = USER_LIST_URI
+ TEST_PASSWORD = 'Password1234'
def get_uri_for_user(self, target_user):
"""Given a user object, get the URI for the corresponding resource"""
diff --git a/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py b/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py
index 1a5d4fe46b..c585936e21 100644
--- a/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py
+++ b/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py
@@ -23,7 +23,7 @@ class VerificationViewTestsMixinBase:
""" Base class for the tests on verification views """
VIEW_NAME = None
CREATED_AT = datetime.datetime.strptime(FROZEN_TIME, '%Y-%m-%d')
- PASSWORD = 'test'
+ PASSWORD = 'Password1234'
def setUp(self):
freezer = freezegun.freeze_time(FROZEN_TIME)
diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py
index 421a23adf6..b4fcc68db6 100644
--- a/openedx/core/djangoapps/user_api/views.py
+++ b/openedx/core/djangoapps/user_api/views.py
@@ -10,7 +10,7 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAut
from opaque_keys import InvalidKeyError
from opaque_keys.edx import locator
from opaque_keys.edx.keys import CourseKey
-from rest_framework import authentication, generics, status, viewsets
+from rest_framework import generics, status, viewsets
from rest_framework.exceptions import ParseError
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
@@ -31,7 +31,6 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet):
"""
DRF class for interacting with the User ORM object
"""
- authentication_classes = (authentication.SessionAuthentication,)
permission_classes = (ApiKeyHeaderPermission,)
queryset = User.objects.all().prefetch_related("preferences").select_related("profile")
serializer_class = UserSerializer
@@ -43,7 +42,6 @@ class ForumRoleUsersListView(generics.ListAPIView):
"""
Forum roles are represented by a list of user dicts
"""
- authentication_classes = (authentication.SessionAuthentication,)
permission_classes = (ApiKeyHeaderPermission,)
serializer_class = UserSerializer
paginate_by = 10
@@ -67,7 +65,6 @@ class UserPreferenceViewSet(viewsets.ReadOnlyModelViewSet):
"""
DRF class for interacting with the UserPreference ORM
"""
- authentication_classes = (authentication.SessionAuthentication,)
permission_classes = (ApiKeyHeaderPermission,)
queryset = UserPreference.objects.all()
filter_backends = (DjangoFilterBackend,)
@@ -81,7 +78,6 @@ class PreferenceUsersListView(generics.ListAPIView):
"""
DRF class for listing a user's preferences
"""
- authentication_classes = (authentication.SessionAuthentication,)
permission_classes = (ApiKeyHeaderPermission,)
serializer_class = UserSerializer
paginate_by = 10
diff --git a/openedx/core/djangoapps/user_authn/api/tests/test_views.py b/openedx/core/djangoapps/user_authn/api/tests/test_views.py
index 034350a4c9..d6720c46fc 100644
--- a/openedx/core/djangoapps/user_authn/api/tests/test_views.py
+++ b/openedx/core/djangoapps/user_authn/api/tests/test_views.py
@@ -421,9 +421,9 @@ class SendAccountActivationEmail(UserAPITestCase):
Create a user, then log in.
"""
super().setUp()
- self.user = UserFactory()
+ self.user = UserFactory(password=self.TEST_PASSWORD)
Registration().register(self.user)
- result = self.client.login(username=self.user.username, password="test")
+ result = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
assert result, 'Could not log in'
self.path = reverse('send_account_activation_email')
diff --git a/openedx/core/djangoapps/user_authn/tests/utils.py b/openedx/core/djangoapps/user_authn/tests/utils.py
index 4b002eef31..09ca85145f 100644
--- a/openedx/core/djangoapps/user_authn/tests/utils.py
+++ b/openedx/core/djangoapps/user_authn/tests/utils.py
@@ -58,7 +58,7 @@ class AuthAndScopesTestMixin:
self.student.
"""
default_scopes = None
- user_password = 'test'
+ user_password = 'Password1234'
def setUp(self):
super().setUp()
diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py
index e503265d1e..d49e4c439e 100644
--- a/openedx/core/djangoapps/user_authn/views/registration_form.py
+++ b/openedx/core/djangoapps/user_authn/views/registration_form.py
@@ -93,7 +93,7 @@ def contains_url(value):
"""
Validator method to check whether full name contains url
"""
- regex = re.findall(r'https|http?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+', value)
+ regex = re.findall(r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))*', value)
return bool(regex)
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_password.py
index 764a2178af..a403298a6e 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_password.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_password.py
@@ -99,8 +99,8 @@ class TestPasswordChange(CreateAccountMixin, CacheIsolationTestCase):
USERNAME = "heisenberg"
ALTERNATE_USERNAME = "walt"
- OLD_PASSWORD = "ḅḷüëṡḳÿ"
- NEW_PASSWORD = "B🄸🄶B🄻🅄🄴"
+ OLD_PASSWORD = "ḅḷüëṡḳÿ1"
+ NEW_PASSWORD = "B🄸🄶B🄻🅄🄴1"
OLD_EMAIL = "walter@graymattertech.com"
NEW_EMAIL = "walt@savewalterwhite.com"
@@ -192,11 +192,11 @@ class TestPasswordChange(CreateAccountMixin, CacheIsolationTestCase):
UserFactory.create(
username='edx',
email='edx@example.com',
- password='edx',
+ password='Password1234',
is_superuser=is_superuser,
is_staff=is_staff,
)
- self.client.login(username='edx', password='edx')
+ self.client.login(username='edx', password='Password1234')
response = self._change_password_from_support(email_from_support_tools=self.OLD_EMAIL)
assert response.status_code == 200
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py
index 0bc05545f7..20261ab9a9 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py
@@ -294,6 +294,45 @@ class RegistrationViewValidationErrorTest(
}
)
+ # testing for http/https
+ response = self.client.post(self.url, {
+ "email": "bob@example.com",
+ "name": "http://",
+ "username": "bob",
+ "password": "password",
+ "honor_code": "true",
+ })
+ assert response.status_code == 400
+ response_json = json.loads(response.content.decode('utf-8'))
+ self.assertDictEqual(
+ response_json,
+ {
+ "name": [{"user_message": 'Enter a valid name'}],
+ "error_code": "validation-error"
+ }
+ )
+
+ def test_register_fullname_html_validation_error(self):
+ """
+ Test for catching invalid full name errors
+ """
+ response = self.client.post(self.url, {
+ "email": "bob@example.com",
+ "name": "",
+ "username": "bob",
+ "password": "password",
+ "honor_code": "true",
+ })
+ assert response.status_code == 400
+ response_json = json.loads(response.content.decode('utf-8'))
+ self.assertDictEqual(
+ response_json,
+ {
+ 'name': [{'user_message': 'Full Name cannot contain the following characters: < >'}],
+ "error_code": "validation-error"
+ }
+ )
+
def test_register_duplicate_username_account_validation_error(self):
# Register the first user
response = self.client.post(self.url, {
@@ -2689,20 +2728,30 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase, OpenEdxEventsTestM
{"username": str(USERNAME_INVALID_CHARS_ASCII)}
)
+ @override_settings(AUTH_PASSWORD_VALIDATORS=[
+ create_validator_config(
+ 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4}
+ )
+ ])
def test_password_empty_validation_decision(self):
# 2 is the default setting for minimum length found in lms/envs/common.py
# under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator
- msg = 'This password is too short. It must contain at least 2 characters.'
+ msg = 'This password is too short. It must contain at least 4 characters.'
self.assertValidationDecision(
{'password': ''},
{"password": msg}
)
+ @override_settings(AUTH_PASSWORD_VALIDATORS=[
+ create_validator_config(
+ 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4}
+ )
+ ])
def test_password_bad_min_length_validation_decision(self):
password = 'p'
# 2 is the default setting for minimum length found in lms/envs/common.py
# under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator
- msg = 'This password is too short. It must contain at least 2 characters.'
+ msg = 'This password is too short. It must contain at least 4 characters.'
self.assertValidationDecision(
{'password': password},
{"password": msg}
diff --git a/openedx/core/djangoapps/xblock/runtime/shims.py b/openedx/core/djangoapps/xblock/runtime/shims.py
index abef606bcf..18aa41eb91 100644
--- a/openedx/core/djangoapps/xblock/runtime/shims.py
+++ b/openedx/core/djangoapps/xblock/runtime/shims.py
@@ -141,7 +141,7 @@ class RuntimeShim:
warnings.warn(
"Use of runtime.render_template is deprecated. "
"For template files included with your XBlock (which is preferable), use "
- "xblockutils.resources.ResourceLoader.render_mako_template to render them, or use a JavaScript-based "
+ "xblock.utils.resources.ResourceLoader.render_mako_template to render them, or use a JavaScript-based "
"template instead. For template files that are part of the LMS/Studio, use the 'mako' XBlock service.",
DeprecationWarning, stacklevel=2,
)
diff --git a/openedx/core/djangoapps/xblock/tests/test_utils.py b/openedx/core/djangoapps/xblock/tests/test_utils.py
index c8181165c7..229406e1bc 100644
--- a/openedx/core/djangoapps/xblock/tests/test_utils.py
+++ b/openedx/core/djangoapps/xblock/tests/test_utils.py
@@ -136,6 +136,7 @@ def test_secure_token(param_delta: dict, expected_validation: bool):
SECRET_KEY=params["generation_secret_key"],
XBLOCK_HANDLER_TOKEN_KEYS=params["generation_xblock_handler_token_keys"],
):
+ assert isinstance(reference_time, datetime.datetime)
with freeze_time(reference_time):
token = get_secure_token_for_xblock_handler(
params["generation_user_id"], params["generation_block_key_str"]
@@ -145,7 +146,9 @@ def test_secure_token(param_delta: dict, expected_validation: bool):
SECRET_KEY=params["validation_secret_key"],
XBLOCK_HANDLER_TOKEN_KEYS=params["validation_xblock_handler_token_keys"],
):
- with freeze_time(reference_time + datetime.timedelta(seconds=params["validation_time_delta_s"])):
+ assert isinstance(params["validation_time_delta_s"], int)
+ new_reference_time = reference_time + datetime.timedelta(seconds=float(params["validation_time_delta_s"]))
+ with freeze_time(new_reference_time):
assert (
validate_secure_token_for_xblock_handler(
params["validation_user_id"], params["validation_block_key_str"], token
diff --git a/openedx/core/lib/events.py b/openedx/core/lib/events.py
new file mode 100644
index 0000000000..bc90824d51
--- /dev/null
+++ b/openedx/core/lib/events.py
@@ -0,0 +1,28 @@
+"""Temporary method for use in rolling out a new event producer configuration."""
+
+from django.conf import settings
+from edx_django_utils.monitoring import set_custom_attribute
+
+
+def determine_producer_config_for_signal_and_topic(signal, topic):
+ """
+ Utility method to determine the setting for the given signal and topic in EVENT_BUS_PRODUCER_CONFIG
+
+ Records to New Relic for later analysis.
+
+ Parameters
+ signal (OpenEdxPublicSignal): The signal being sent to the event bus
+ topic (string): The topic to which the signal is being sent (without environment prefix)
+
+ Returns
+ True if the signal is enabled for that topic in EVENT_BUS_PRODUCER_CONFIG
+ False if the signal is explicitly disabled for that topic in EVENT_BUS_PRODUCER_CONFIG
+ None if the signal/topic pair is not present in EVENT_BUS_PRODUCER_CONFIG
+ """
+ event_type_producer_configs = getattr(settings, "EVENT_BUS_PRODUCER_CONFIG",
+ {}).get(signal.event_type, {})
+ topic_config = event_type_producer_configs.get(topic, {})
+ topic_setting = topic_config.get('enabled', None)
+ set_custom_attribute(f'producer_config_setting_{topic}_{signal.event_type}',
+ topic_setting if topic_setting is not None else 'Unset')
+ return topic_setting
diff --git a/openedx/features/course_duration_limits/tests/test_course_expiration.py b/openedx/features/course_duration_limits/tests/test_course_expiration.py
index 32a352d66e..b794ef3076 100644
--- a/openedx/features/course_duration_limits/tests/test_course_expiration.py
+++ b/openedx/features/course_duration_limits/tests/test_course_expiration.py
@@ -249,7 +249,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
mode='audit'
)
CourseInstructorRole(self.course.id).add_users(instructor)
- self.client.login(username=instructor.username, password='test')
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
self.update_masquerade(**masquerade_config)
@@ -285,7 +285,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
mode='audit'
)
CourseInstructorRole(self.course.id).add_users(instructor)
- self.client.login(username=instructor.username, password='test')
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
self.update_masquerade(username='audit')
@@ -320,7 +320,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
mode='audit'
)
CourseInstructorRole(self.course.id).add_users(instructor)
- self.client.login(username=instructor.username, password='test')
+ self.client.login(username=instructor.username, password=self.TEST_PASSWORD)
self.update_masquerade(username='audit')
@@ -370,7 +370,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
mode='audit'
)
- self.client.login(username=staff_user.username, password='test')
+ self.client.login(username=staff_user.username, password=self.TEST_PASSWORD)
self.update_masquerade(username=expired_staff.username)
@@ -418,7 +418,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin):
mode='audit'
)
- self.client.login(username=staff_user.username, password='test')
+ self.client.login(username=staff_user.username, password=self.TEST_PASSWORD)
self.update_masquerade(username=expired_staff.username)
diff --git a/openedx/features/course_experience/tests/views/test_course_sock.py b/openedx/features/course_experience/tests/views/test_course_sock.py
index cdc80168ab..5c612e3ca8 100644
--- a/openedx/features/course_experience/tests/views/test_course_sock.py
+++ b/openedx/features/course_experience/tests/views/test_course_sock.py
@@ -20,7 +20,7 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p
from .helpers import add_course_mode
-TEST_PASSWORD = 'test'
+TEST_PASSWORD = 'Password1234'
TEST_VERIFICATION_SOCK_LOCATOR = '')
- });
- };
-
- beforeEach(function() {
- view = createModalView();
- // Attach view to document, otherwise click won't work
- view.render();
- $('body').append(view.$el);
- view.$el.show();
- expect(view.$el.is(':visible')).toBe(true);
- });
-
- afterEach(function() {
- view.$el.remove();
- });
-
- it('modal view closes on escape', function() {
- spyOn(view, 'close');
- view.delegateEvents();
- expect(view.close).not.toHaveBeenCalled();
- $(view.$el).simulate('keydown', {keyCode: keys.ESCAPE});
- expect(view.close).toHaveBeenCalled();
- });
-
- it('modal view closes click on close', function() {
- var $closeButton;
- spyOn(view, 'close');
- view.delegateEvents();
- $closeButton = view.$el.find('button.close');
- expect($closeButton.length).toBe(1);
- expect(view.close).not.toHaveBeenCalled();
- $closeButton.trigger('click');
- expect(view.close).toHaveBeenCalled();
- });
- });
- }
-);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js b/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js
index 5d4a278907..e1369284a4 100644
--- a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js
+++ b/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js
@@ -123,137 +123,11 @@ define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'
expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true);
};
- var expectBadgesDisplayed = function(learnerProfileView, length, lastPage) {
- var $badgeListingView = $('#tabpanel-accomplishments'),
- updatedLength = length,
- placeholder;
- expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(true);
- expect($badgeListingView.hasClass('is-hidden')).toBe(false);
- if (lastPage) {
- updatedLength += 1;
- placeholder = $badgeListingView.find('.find-course');
- expect(placeholder.length).toBe(1);
- expect(placeholder.attr('href')).toBe('/courses/');
- }
- expect($badgeListingView.find('.badge-display').length).toBe(updatedLength);
- };
-
- var expectBadgesHidden = function() {
- var $accomplishmentsTab = $('#tabpanel-accomplishments');
- if ($accomplishmentsTab.length) {
- // Nonexistence counts as hidden.
- expect($('#tabpanel-accomplishments').hasClass('is-hidden')).toBe(true);
- }
- expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(false);
- };
-
- var expectPage = function(learnerProfileView, pageData) {
- var $badgeListContainer = $('#tabpanel-accomplishments');
- var index = $badgeListContainer.find('span.search-count').text().trim();
- expect(index).toBe('Showing ' + (pageData.start + 1) + '-' + (pageData.start + pageData.results.length)
- + ' out of ' + pageData.count + ' total');
- expect($badgeListContainer.find('.current-page').text()).toBe('' + pageData.current_page);
- _.each(pageData.results, function(badge) {
- expect($('.badge-display:contains(' + badge.badge_class.display_name + ')').length).toBe(1);
- });
- };
-
- var expectBadgeLoadingErrorIsRendered = function() {
- var errorMessage = $('.badge-set-display').text();
- expect(errorMessage).toBe(
- 'Your request could not be completed. Reload the page and try again. If the issue persists, click the '
- + 'Help tab to report the problem.'
- );
- };
-
- var breakBadgeLoading = function(learnerProfileView, requests) {
- var request = AjaxHelpers.currentRequest(requests);
- var path = new URI(request.url).path();
- expect(path).toBe('/api/badges/v1/assertions/user/student/');
- AjaxHelpers.respondWithError(requests, 500);
- };
-
- var firstPageBadges = {
- count: 30,
- previous: null,
- next: '/arbitrary/url',
- num_pages: 3,
- start: 0,
- current_page: 1,
- results: []
- };
-
- var secondPageBadges = {
- count: 30,
- previous: '/arbitrary/url',
- next: '/arbitrary/url',
- num_pages: 3,
- start: 10,
- current_page: 2,
- results: []
- };
-
- var thirdPageBadges = {
- count: 30,
- previous: '/arbitrary/url',
- num_pages: 3,
- next: null,
- start: 20,
- current_page: 3,
- results: []
- };
-
- var emptyBadges = {
- count: 0,
- previous: null,
- num_pages: 1,
- results: []
- };
-
- function makeBadge(num) {
- return {
- badge_class: {
- slug: 'test_slug_' + num,
- issuing_component: 'test_component',
- display_name: 'Test Badge ' + num,
- course_id: null,
- description: "Yay! It's a test badge.",
- criteria: 'https://example.com/syllabus',
- image_url: 'http://localhost:8000/media/badge_classes/test_lMB9bRw.png'
- },
- image_url: 'http://example.com/image.png',
- assertion_url: 'http://example.com/example.json',
- created_at: '2015-12-03T16:25:57.676113Z'
- };
- }
-
- _.each(_.range(0, 10), function(i) {
- firstPageBadges.results.push(makeBadge(i));
- });
-
- _.each(_.range(10, 20), function(i) {
- secondPageBadges.results.push(makeBadge(i));
- });
-
- _.each(_.range(20, 30), function(i) {
- thirdPageBadges.results.push(makeBadge(i));
- });
-
return {
expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered,
expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered,
expectTabbedViewToBeUndefined: expectTabbedViewToBeUndefined,
- expectTabbedViewToBeShown: expectTabbedViewToBeShown,
- expectBadgesDisplayed: expectBadgesDisplayed,
- expectBadgesHidden: expectBadgesHidden,
- expectBadgeLoadingErrorIsRendered: expectBadgeLoadingErrorIsRendered,
- breakBadgeLoading: breakBadgeLoading,
- firstPageBadges: firstPageBadges,
- secondPageBadges: secondPageBadges,
- thirdPageBadges: thirdPageBadges,
- emptyBadges: emptyBadges,
- expectPage: expectPage,
- makeBadge: makeBadge
+ expectTabbedViewToBeShown: expectTabbedViewToBeShown
};
});
diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js
deleted file mode 100644
index ca68ca8fa9..0000000000
--- a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js
+++ /dev/null
@@ -1,35 +0,0 @@
-/* eslint-disable no-underscore-dangle */
-(function(define) {
- 'use strict';
-
- define(
- [
- 'gettext', 'jquery', 'underscore', 'common/js/components/views/paginated_view',
- 'learner_profile/js/views/badge_view', 'learner_profile/js/views/badge_list_view',
- 'text!learner_profile/templates/badge_list.underscore'
- ],
- function(gettext, $, _, PaginatedView, BadgeView, BadgeListView, BadgeListTemplate) {
- var BadgeListContainer = PaginatedView.extend({
- type: 'badge',
-
- itemViewClass: BadgeView,
-
- listViewClass: BadgeListView,
-
- viewTemplate: BadgeListTemplate,
-
- isZeroIndexed: true,
-
- paginationLabel: gettext('Accomplishments Pagination'),
-
- initialize: function(options) {
- BadgeListContainer.__super__.initialize.call(this, options);
- this.listView.find_courses_url = options.find_courses_url;
- this.listView.badgeMeta = options.badgeMeta;
- this.listView.ownProfile = options.ownProfile;
- }
- });
-
- return BadgeListContainer;
- });
-}).call(this, define || RequireJS.define);
diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js
deleted file mode 100644
index c30ab9c94a..0000000000
--- a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js
+++ /dev/null
@@ -1,65 +0,0 @@
-(function(define) {
- 'use strict';
-
- define([
- 'gettext',
- 'jquery',
- 'underscore',
- 'edx-ui-toolkit/js/utils/html-utils',
- 'common/js/components/views/list',
- 'learner_profile/js/views/badge_view',
- 'text!learner_profile/templates/badge_placeholder.underscore'
- ],
- function(gettext, $, _, HtmlUtils, ListView, BadgeView, badgePlaceholder) {
- var BadgeListView = ListView.extend({
- tagName: 'div',
-
- template: HtmlUtils.template(badgePlaceholder),
-
- renderCollection: function() {
- var self = this,
- $row;
-
- this.$el.empty();
-
- // Split into two columns.
- this.collection.each(function(badge, index) {
- var $item;
- if (index % 2 === 0) {
- $row = $('
');
- this.$el.append($row);
- }
- $item = new BadgeView({
- model: badge,
- badgeMeta: this.badgeMeta,
- ownProfile: this.ownProfile
- }).render().el;
-
- if ($row) {
- $row.append($item);
- }
-
- this.itemViews.push($item);
- }, this);
- // Placeholder must always be at the end, and may need a new row.
- if (!this.collection.hasNextPage()) {
- // find_courses_url set by BadgeListContainer during initialization.
- if (this.collection.length % 2 === 0) {
- $row = $('
<%- gettext("To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.") %>
-
-
-
-
-
- <%= edx.HtmlUtils.interpolateHtml(
- gettext("Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account"),
- {
- link_start: edx.HtmlUtils.HTML(''),
- link_end: edx.HtmlUtils.HTML('')
- }
- )
- %>
-
-
-
- <%= edx.HtmlUtils.interpolateHtml(
- gettext("{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack."),
- {
- download_link_start: edx.HtmlUtils.joinHtml(
- edx.HtmlUtils.HTML(''),
- ),
- link_end: edx.HtmlUtils.HTML(''),
- upload_link_start: edx.HtmlUtils.HTML('')
- }
- )
- %>
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/openedx/features/learner_profile/views/learner_profile.py b/openedx/features/learner_profile/views/learner_profile.py
index e19e6853e8..6a3a251fde 100644
--- a/openedx/features/learner_profile/views/learner_profile.py
+++ b/openedx/features/learner_profile/views/learner_profile.py
@@ -11,7 +11,6 @@ from django.urls import reverse
from django.views.decorators.http import require_http_methods
from django_countries import countries
-from lms.djangoapps.badges.utils import badges_enabled
from common.djangoapps.edxmako.shortcuts import marketing_link
from openedx.core.djangoapps.credentials.utils import get_credentials_records_url
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
@@ -105,8 +104,6 @@ def learner_profile_context(request, profile_username, user_is_staff):
'country_options': list(countries),
'find_courses_url': marketing_link('COURSES'),
'language_options': settings.ALL_LANGUAGES,
- 'badges_logo': staticfiles_storage.url('certificates/images/backpack-logo.png'),
- 'badges_icon': staticfiles_storage.url('certificates/images/ico-mozillaopenbadges.png'),
'backpack_ui_img': staticfiles_storage.url('certificates/images/backpack-ui.png'),
'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME),
'social_platforms': settings.SOCIAL_PLATFORMS,
@@ -128,7 +125,4 @@ def learner_profile_context(request, profile_username, user_is_staff):
)
context['achievements_fragment'] = achievements_fragment
- if badges_enabled():
- context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username})
-
return context
diff --git a/openedx/tests/xblock_integration/test_crowdsource_hinter.py b/openedx/tests/xblock_integration/test_crowdsource_hinter.py
index a2fffdc6e9..61f1097481 100644
--- a/openedx/tests/xblock_integration/test_crowdsource_hinter.py
+++ b/openedx/tests/xblock_integration/test_crowdsource_hinter.py
@@ -21,8 +21,8 @@ class TestCrowdsourceHinter(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
Create the test environment with the crowdsourcehinter xblock.
"""
STUDENTS = [
- {'email': 'view@test.com', 'password': 'foo'},
- {'email': 'view2@test.com', 'password': 'foo'}
+ {'email': 'view@test.com', 'password': SharedModuleStoreTestCase.TEST_PASSWORD},
+ {'email': 'view2@test.com', 'password': SharedModuleStoreTestCase.TEST_PASSWORD}
]
XBLOCK_NAMES = ['crowdsourcehinter']
diff --git a/openedx/tests/xblock_integration/test_recommender.py b/openedx/tests/xblock_integration/test_recommender.py
index a494fc9c13..ae06b09db1 100644
--- a/openedx/tests/xblock_integration/test_recommender.py
+++ b/openedx/tests/xblock_integration/test_recommender.py
@@ -27,8 +27,8 @@ class TestRecommender(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
Check that Recommender state is saved properly
"""
STUDENTS = [
- {'email': 'view@test.com', 'password': 'foo'},
- {'email': 'view2@test.com', 'password': 'foo'}
+ {'email': 'view@test.com', 'password': 'Password1234'},
+ {'email': 'view2@test.com', 'password': 'Password1234'}
]
XBLOCK_NAMES = ['recommender', 'recommender_second']
@@ -141,7 +141,7 @@ class TestRecommender(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
Staff login and enroll for the course
"""
email = staff.email
- password = 'test'
+ password = self.TEST_PASSWORD
self.login(email, password)
self.enroll(self.course, verify=True)
diff --git a/openedx/tests/xblock_integration/xblock_testcase.py b/openedx/tests/xblock_integration/xblock_testcase.py
index a6a4ac67a4..7e6d9a9387 100644
--- a/openedx/tests/xblock_integration/xblock_testcase.py
+++ b/openedx/tests/xblock_integration/xblock_testcase.py
@@ -282,9 +282,9 @@ class XBlockStudentTestCaseMixin:
Creates a default set of students for XBlock tests
'''
student_list = [
- {'email': 'alice@test.edx.org', 'password': 'foo'},
- {'email': 'bob@test.edx.org', 'password': 'foo'},
- {'email': 'eve@test.edx.org', 'password': 'foo'},
+ {'email': 'alice@test.edx.org', 'password': 'Password1234'},
+ {'email': 'bob@test.edx.org', 'password': 'Password1234'},
+ {'email': 'eve@test.edx.org', 'password': 'Password1234'},
]
def setUp(self):
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index 77f60e9937..5e99182b7b 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -26,7 +26,7 @@ django-storages==1.14
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
-edx-enterprise==4.5.0
+edx-enterprise==4.6.7
# django-oauth-toolkit version >=2.0.0 has breaking changes. More details
# mentioned on this issue https://github.com/openedx/edx-platform/issues/32884
@@ -121,7 +121,7 @@ libsass==0.10.0
click==8.1.6
# pinning this version to avoid updates while the library is being developed
-openedx-learning==0.1.7
+openedx-learning==0.2.5
# lti-consumer-xblock 9.6.2 contains a breaking change that makes
# existing custom parameter configurations unusable.
diff --git a/requirements/edx-sandbox/py38.txt b/requirements/edx-sandbox/py38.txt
index d1cc50e869..c4674d259c 100644
--- a/requirements/edx-sandbox/py38.txt
+++ b/requirements/edx-sandbox/py38.txt
@@ -20,9 +20,9 @@ cryptography==38.0.4
# via
# -c requirements/edx-sandbox/../constraints.txt
# -r requirements/edx-sandbox/py38.in
-cycler==0.12.0
+cycler==0.12.1
# via matplotlib
-fonttools==4.43.0
+fonttools==4.43.1
# via matplotlib
importlib-resources==6.1.0
# via matplotlib
@@ -75,7 +75,7 @@ python-dateutil==2.8.2
# via matplotlib
random2==1.0.1
# via -r requirements/edx-sandbox/py38.in
-regex==2023.8.8
+regex==2023.10.3
# via nltk
scipy==1.7.3
# via
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 63dfd49229..1c9d41bbd4 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -4,9 +4,11 @@
#
# make upgrade
#
+-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
+ # via -r requirements/edx/github.in
acid-xblock==0.2.1
# via -r requirements/edx/kernel.in
-aiohttp==3.8.5
+aiohttp==3.8.6
# via
# geoip2
# openai
@@ -64,7 +66,7 @@ beautifulsoup4==4.12.2
# via pynliner
billiard==4.1.0
# via celery
-bleach[css]==6.0.0
+bleach[css]==6.1.0
# via
# -r requirements/edx/kernel.in
# edx-enterprise
@@ -77,13 +79,13 @@ boto==2.39.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
-boto3==1.28.58
+boto3==1.28.62
# via
# -r requirements/edx/kernel.in
# django-ses
# fs-s3fs
# ora2
-botocore==1.31.58
+botocore==1.31.62
# via
# -r requirements/edx/kernel.in
# boto3
@@ -178,7 +180,7 @@ defusedxml==0.7.1
# social-auth-core
deprecated==1.2.14
# via jwcrypto
-django==3.2.21
+django==3.2.22
# via
# -c requirements/edx/../common_constraints.txt
# -r requirements/edx/kernel.in
@@ -424,7 +426,7 @@ edx-auth-backends==4.2.0
# via
# -r requirements/edx/kernel.in
# openedx-blockstore
-edx-braze-client==0.1.7
+edx-braze-client==0.1.8
# via
# -r requirements/edx/bundled.in
# edx-enterprise
@@ -468,7 +470,7 @@ edx-django-utils==5.7.0
# openedx-blockstore
# ora2
# super-csv
-edx-drf-extensions==8.10.0
+edx-drf-extensions==8.12.0
# via
# -r requirements/edx/kernel.in
# edx-completion
@@ -480,15 +482,15 @@ edx-drf-extensions==8.10.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.5.0
+edx-enterprise==4.6.7
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
edx-event-bus-kafka==5.5.0
# via -r requirements/edx/kernel.in
-edx-event-bus-redis==0.3.1
+edx-event-bus-redis==0.3.2
# via -r requirements/edx/kernel.in
-edx-i18n-tools==1.2.0
+edx-i18n-tools==1.3.0
# via ora2
edx-milestones==0.5.0
# via -r requirements/edx/kernel.in
@@ -516,8 +518,6 @@ edx-proctoring==4.16.1
# via
# -r requirements/edx/kernel.in
# edx-proctoring-proctortrack
-edx-proctoring-proctortrack==1.2.1
- # via -r requirements/edx/kernel.in
edx-rbac==1.8.0
# via edx-enterprise
edx-rest-api-client==5.6.0
@@ -552,7 +552,7 @@ edx-when==2.4.0
# via
# -r requirements/edx/kernel.in
# edx-proctoring
-edxval==2.4.3
+edxval==2.4.4
# via -r requirements/edx/kernel.in
elasticsearch==7.13.4
# via
@@ -567,7 +567,7 @@ event-tracking==2.2.0
# -r requirements/edx/kernel.in
# edx-proctoring
# edx-search
-fastavro==1.8.3
+fastavro==1.8.4
# via openedx-events
filelock==3.12.4
# via snowflake-connector-python
@@ -681,6 +681,7 @@ lti-consumer-xblock==9.6.1
lxml==4.9.3
# via
# -r requirements/edx/kernel.in
+ # edx-i18n-tools
# edxval
# lti-consumer-xblock
# olxcleaner
@@ -696,6 +697,7 @@ mako==1.2.4
# -r requirements/edx/kernel.in
# acid-xblock
# lti-consumer-xblock
+ # xblock
# xblock-google-drive
# xblock-utils
markdown==3.3.7
@@ -774,7 +776,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/kernel.in
openedx-django-wiki==2.0.3
# via -r requirements/edx/kernel.in
-openedx-events==8.8.0
+openedx-events==9.0.0
# via
# -r requirements/edx/kernel.in
# edx-event-bus-kafka
@@ -783,7 +785,7 @@ openedx-filters==1.6.0
# via
# -r requirements/edx/kernel.in
# lti-consumer-xblock
-openedx-learning==0.1.7
+openedx-learning==0.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
@@ -791,7 +793,7 @@ openedx-mongodbproxy==0.2.0
# via -r requirements/edx/kernel.in
optimizely-sdk==4.1.1
# via -r requirements/edx/bundled.in
-ora2==5.5.3
+ora2==5.5.5
# via -r requirements/edx/bundled.in
oscrypto==1.3.0
# via snowflake-connector-python
@@ -833,7 +835,7 @@ pillow==9.5.0
# edxval
pkgutil-resolve-name==1.3.10
# via jsonschema
-platformdirs==3.8.1
+platformdirs==3.11.0
# via snowflake-connector-python
polib==1.2.0
# via edx-i18n-tools
@@ -843,7 +845,7 @@ psutil==5.9.5
# via
# -r requirements/edx/paver.txt
# edx-django-utils
-py2neo==2021.2.3
+py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/bundled.in
@@ -937,7 +939,7 @@ python3-openid==3.2.0 ; python_version >= "3"
# via
# -r requirements/edx/kernel.in
# social-auth-core
-python3-saml==1.15.0
+python3-saml==1.16.0
# via -r requirements/edx/kernel.in
pytz==2022.7.1
# via
@@ -984,7 +986,7 @@ referencing==0.30.2
# via
# jsonschema
# jsonschema-specifications
-regex==2023.8.8
+regex==2023.10.3
# via nltk
requests==2.31.0
# via
@@ -1013,13 +1015,13 @@ requests-oauthlib==1.3.1
# via
# -r requirements/edx/kernel.in
# social-auth-core
-rpds-py==0.10.3
+rpds-py==0.10.4
# via
# jsonschema
# referencing
-ruamel-yaml==0.17.33
+ruamel-yaml==0.17.35
# via drf-yasg
-ruamel-yaml-clib==0.2.7
+ruamel-yaml-clib==0.2.8
# via ruamel-yaml
rules==3.3
# via
@@ -1040,11 +1042,12 @@ semantic-version==2.10.0
# via edx-drf-extensions
shapely==2.0.1
# via -r requirements/edx/kernel.in
-simplejson==3.19.1
+simplejson==3.19.2
# via
# -r requirements/edx/kernel.in
# sailthru-client
# super-csv
+ # xblock
# xblock-utils
six==1.16.0
# via
@@ -1081,7 +1084,7 @@ slumber==0.7.1
# edx-bulk-grades
# edx-enterprise
# edx-rest-api-client
-snowflake-connector-python==3.2.0
+snowflake-connector-python==3.2.1
# via edx-enterprise
social-auth-app-django==5.0.0
# via
@@ -1094,7 +1097,7 @@ social-auth-core==4.3.0
# -r requirements/edx/kernel.in
# edx-auth-backends
# social-auth-app-django
-sorl-thumbnail==12.9.0
+sorl-thumbnail==12.10.0
# via
# -r requirements/edx/kernel.in
# openedx-django-wiki
@@ -1128,7 +1131,7 @@ testfixtures==7.2.0
# via edx-enterprise
text-unidecode==1.3
# via python-slugify
-tinycss2==1.1.1
+tinycss2==1.2.1
# via bleach
tomlkit==0.12.1
# via snowflake-connector-python
@@ -1203,7 +1206,7 @@ wrapt==1.15.0
# via
# -r requirements/edx/paver.txt
# deprecated
-xblock[django]==1.8.0
+xblock[django]==1.8.1
# via
# -r requirements/edx/kernel.in
# acid-xblock
@@ -1224,9 +1227,8 @@ xblock-google-drive==0.4.0
# via -r requirements/edx/bundled.in
xblock-poll==1.13.0
# via -r requirements/edx/bundled.in
-xblock-utils==3.4.1
+xblock-utils==4.0.0
# via
- # -r requirements/edx/kernel.in
# done-xblock
# edx-sga
# lti-consumer-xblock
diff --git a/requirements/edx/bundled.in b/requirements/edx/bundled.in
index e115f667df..b8ada003ca 100644
--- a/requirements/edx/bundled.in
+++ b/requirements/edx/bundled.in
@@ -20,7 +20,11 @@
# 4. If the package is not needed in production, add it to another file such
# as development.in or testing.in instead.
-py2neo # Driver for converting Python modulestore structures to Neo4j's schema (for Coursegraph).
+# Driver for converting Python modulestore structures to Neo4j's schema (for Coursegraph).
+# Using the fork because official package has been removed from PyPI/GitHub
+# Follow up issue to remove this fork: https://github.com/openedx/edx-platform/issues/33456
+https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz
+
pygments # Used to support colors in paver command output
## Third party integrations
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index 0f42820cfe..ab4954475c 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -4,6 +4,10 @@
#
# make upgrade
#
+-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
accessible-pygments==0.0.4
# via
# -r requirements/edx/doc.txt
@@ -12,7 +16,7 @@ acid-xblock==0.2.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-aiohttp==3.8.5
+aiohttp==3.8.6
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -46,7 +50,7 @@ aniso8601==9.0.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-tincan-py35
-annotated-types==0.5.0
+annotated-types==0.6.0
# via
# -r requirements/edx/testing.txt
# pydantic
@@ -128,7 +132,7 @@ billiard==4.1.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# celery
-bleach[css]==6.0.0
+bleach[css]==6.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -143,14 +147,14 @@ boto==2.39.0
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-boto3==1.28.58
+boto3==1.28.62
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.31.58
+botocore==1.31.62
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -333,7 +337,7 @@ distlib==0.3.7
# via
# -r requirements/edx/testing.txt
# virtualenv
-django==3.2.21
+django==3.2.22
# via
# -c requirements/edx/../common_constraints.txt
# -r requirements/edx/doc.txt
@@ -679,7 +683,7 @@ edx-auth-backends==4.2.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# openedx-blockstore
-edx-braze-client==0.1.7
+edx-braze-client==0.1.8
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -735,7 +739,7 @@ edx-django-utils==5.7.0
# openedx-blockstore
# ora2
# super-csv
-edx-drf-extensions==8.10.0
+edx-drf-extensions==8.12.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -748,7 +752,7 @@ edx-drf-extensions==8.10.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.5.0
+edx-enterprise==4.6.7
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -757,11 +761,11 @@ edx-event-bus-kafka==5.5.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-event-bus-redis==0.3.1
+edx-event-bus-redis==0.3.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-i18n-tools==1.2.0
+edx-i18n-tools==1.3.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -801,10 +805,6 @@ edx-proctoring==4.16.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-proctoring-proctortrack
-edx-proctoring-proctortrack==1.2.1
- # via
- # -r requirements/edx/doc.txt
- # -r requirements/edx/testing.txt
edx-rbac==1.8.0
# via
# -r requirements/edx/doc.txt
@@ -855,7 +855,7 @@ edx-when==2.4.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-proctoring
-edxval==2.4.3
+edxval==2.4.4
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -891,7 +891,7 @@ execnet==2.0.2
# pytest-xdist
factory-boy==3.3.0
# via -r requirements/edx/testing.txt
-faker==19.6.2
+faker==19.9.0
# via
# -r requirements/edx/testing.txt
# factory-boy
@@ -899,7 +899,7 @@ fastapi==0.103.2
# via
# -r requirements/edx/testing.txt
# pact-python
-fastavro==1.8.3
+fastavro==1.8.4
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1143,6 +1143,7 @@ lxml==4.9.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
+ # edx-i18n-tools
# edxval
# lti-consumer-xblock
# olxcleaner
@@ -1162,6 +1163,7 @@ mako==1.2.4
# -r requirements/edx/testing.txt
# acid-xblock
# lti-consumer-xblock
+ # xblock
# xblock-google-drive
# xblock-utils
markdown==3.3.7
@@ -1224,7 +1226,7 @@ multidict==6.0.4
# -r requirements/edx/testing.txt
# aiohttp
# yarl
-mypy==1.5.1
+mypy==1.6.0
# via
# -r requirements/edx/development.in
# django-stubs
@@ -1302,7 +1304,7 @@ openedx-django-wiki==2.0.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-openedx-events==8.8.0
+openedx-events==9.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1313,7 +1315,7 @@ openedx-filters==1.6.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# lti-consumer-xblock
-openedx-learning==0.1.7
+openedx-learning==0.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -1326,7 +1328,7 @@ optimizely-sdk==4.1.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-ora2==5.5.3
+ora2==5.5.5
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1406,7 +1408,7 @@ pkgutil-resolve-name==1.3.10
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# jsonschema
-platformdirs==3.8.1
+platformdirs==3.11.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1440,7 +1442,7 @@ py==1.11.0
# via
# -r requirements/edx/testing.txt
# tox
-py2neo==2021.2.3
+py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -1657,7 +1659,7 @@ python3-openid==3.2.0 ; python_version >= "3"
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# social-auth-core
-python3-saml==1.15.0
+python3-saml==1.16.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1720,7 +1722,7 @@ referencing==0.30.2
# -r requirements/edx/testing.txt
# jsonschema
# jsonschema-specifications
-regex==2023.8.8
+regex==2023.10.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1761,18 +1763,18 @@ rfc3986[idna2008]==1.5.0
# via
# -r requirements/edx/testing.txt
# httpx
-rpds-py==0.10.3
+rpds-py==0.10.4
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# jsonschema
# referencing
-ruamel-yaml==0.17.33
+ruamel-yaml==0.17.35
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# drf-yasg
-ruamel-yaml-clib==0.2.7
+ruamel-yaml-clib==0.2.8
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1810,12 +1812,13 @@ shapely==2.0.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-simplejson==3.19.1
+simplejson==3.19.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# sailthru-client
# super-csv
+ # xblock
# xblock-utils
singledispatch==4.1.0
# via -r requirements/edx/testing.txt
@@ -1875,7 +1878,7 @@ snowballstemmer==2.2.0
# via
# -r requirements/edx/doc.txt
# sphinx
-snowflake-connector-python==3.2.0
+snowflake-connector-python==3.2.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1893,7 +1896,7 @@ social-auth-core==4.3.0
# -r requirements/edx/testing.txt
# edx-auth-backends
# social-auth-app-django
-sorl-thumbnail==12.9.0
+sorl-thumbnail==12.10.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2004,7 +2007,7 @@ text-unidecode==1.3
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# python-slugify
-tinycss2==1.1.1
+tinycss2==1.2.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2126,7 +2129,7 @@ vine==5.0.0
# amqp
# celery
# kombu
-virtualenv==20.24.1
+virtualenv==20.24.5
# via
# -r requirements/edx/testing.txt
# tox
@@ -2135,7 +2138,7 @@ voluptuous==0.13.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# ora2
-vulture==2.9.1
+vulture==2.10
# via -r requirements/edx/development.in
walrus==0.9.3
# via
@@ -2183,7 +2186,7 @@ wrapt==1.15.0
# -r requirements/edx/testing.txt
# astroid
# deprecated
-xblock[django]==1.8.0
+xblock[django]==1.8.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -2211,7 +2214,7 @@ xblock-poll==1.13.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-xblock-utils==3.4.1
+xblock-utils==4.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index 2ccab0b634..fef17b68d5 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -4,11 +4,13 @@
#
# make upgrade
#
+-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
+ # via -r requirements/edx/base.txt
accessible-pygments==0.0.4
# via pydata-sphinx-theme
acid-xblock==0.2.1
# via -r requirements/edx/base.txt
-aiohttp==3.8.5
+aiohttp==3.8.6
# via
# -r requirements/edx/base.txt
# geoip2
@@ -90,7 +92,7 @@ billiard==4.1.0
# via
# -r requirements/edx/base.txt
# celery
-bleach[css]==6.0.0
+bleach[css]==6.1.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -103,13 +105,13 @@ boto==2.39.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-boto3==1.28.58
+boto3==1.28.62
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.31.58
+botocore==1.31.62
# via
# -r requirements/edx/base.txt
# boto3
@@ -225,7 +227,7 @@ deprecated==1.2.14
# via
# -r requirements/edx/base.txt
# jwcrypto
-django==3.2.21
+django==3.2.22
# via
# -c requirements/edx/../common_constraints.txt
# -r requirements/edx/base.txt
@@ -500,7 +502,7 @@ edx-auth-backends==4.2.0
# via
# -r requirements/edx/base.txt
# openedx-blockstore
-edx-braze-client==0.1.7
+edx-braze-client==0.1.8
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -544,7 +546,7 @@ edx-django-utils==5.7.0
# openedx-blockstore
# ora2
# super-csv
-edx-drf-extensions==8.10.0
+edx-drf-extensions==8.12.0
# via
# -r requirements/edx/base.txt
# edx-completion
@@ -556,15 +558,15 @@ edx-drf-extensions==8.10.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.5.0
+edx-enterprise==4.6.7
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.5.0
# via -r requirements/edx/base.txt
-edx-event-bus-redis==0.3.1
+edx-event-bus-redis==0.3.2
# via -r requirements/edx/base.txt
-edx-i18n-tools==1.2.0
+edx-i18n-tools==1.3.0
# via
# -r requirements/edx/base.txt
# ora2
@@ -593,8 +595,6 @@ edx-proctoring==4.16.1
# via
# -r requirements/edx/base.txt
# edx-proctoring-proctortrack
-edx-proctoring-proctortrack==1.2.1
- # via -r requirements/edx/base.txt
edx-rbac==1.8.0
# via
# -r requirements/edx/base.txt
@@ -633,7 +633,7 @@ edx-when==2.4.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
-edxval==2.4.3
+edxval==2.4.4
# via -r requirements/edx/base.txt
elasticsearch==7.13.4
# via
@@ -651,7 +651,7 @@ event-tracking==2.2.0
# -r requirements/edx/base.txt
# edx-proctoring
# edx-search
-fastavro==1.8.3
+fastavro==1.8.4
# via
# -r requirements/edx/base.txt
# openedx-events
@@ -805,6 +805,7 @@ lti-consumer-xblock==9.6.1
lxml==4.9.3
# via
# -r requirements/edx/base.txt
+ # edx-i18n-tools
# edxval
# lti-consumer-xblock
# olxcleaner
@@ -820,6 +821,7 @@ mako==1.2.4
# -r requirements/edx/base.txt
# acid-xblock
# lti-consumer-xblock
+ # xblock
# xblock-google-drive
# xblock-utils
markdown==3.3.7
@@ -914,7 +916,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/base.txt
openedx-django-wiki==2.0.3
# via -r requirements/edx/base.txt
-openedx-events==8.8.0
+openedx-events==9.0.0
# via
# -r requirements/edx/base.txt
# edx-event-bus-kafka
@@ -923,7 +925,7 @@ openedx-filters==1.6.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
-openedx-learning==0.1.7
+openedx-learning==0.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -931,7 +933,7 @@ openedx-mongodbproxy==0.2.0
# via -r requirements/edx/base.txt
optimizely-sdk==4.1.1
# via -r requirements/edx/base.txt
-ora2==5.5.3
+ora2==5.5.5
# via -r requirements/edx/base.txt
oscrypto==1.3.0
# via
@@ -986,7 +988,7 @@ pkgutil-resolve-name==1.3.10
# via
# -r requirements/edx/base.txt
# jsonschema
-platformdirs==3.8.1
+platformdirs==3.11.0
# via
# -r requirements/edx/base.txt
# snowflake-connector-python
@@ -1002,7 +1004,7 @@ psutil==5.9.5
# via
# -r requirements/edx/base.txt
# edx-django-utils
-py2neo==2021.2.3
+py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -1117,7 +1119,7 @@ python3-openid==3.2.0 ; python_version >= "3"
# via
# -r requirements/edx/base.txt
# social-auth-core
-python3-saml==1.15.0
+python3-saml==1.16.0
# via -r requirements/edx/base.txt
pytz==2022.7.1
# via
@@ -1166,7 +1168,7 @@ referencing==0.30.2
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
-regex==2023.8.8
+regex==2023.10.3
# via
# -r requirements/edx/base.txt
# nltk
@@ -1198,16 +1200,16 @@ requests-oauthlib==1.3.1
# via
# -r requirements/edx/base.txt
# social-auth-core
-rpds-py==0.10.3
+rpds-py==0.10.4
# via
# -r requirements/edx/base.txt
# jsonschema
# referencing
-ruamel-yaml==0.17.33
+ruamel-yaml==0.17.35
# via
# -r requirements/edx/base.txt
# drf-yasg
-ruamel-yaml-clib==0.2.7
+ruamel-yaml-clib==0.2.8
# via
# -r requirements/edx/base.txt
# ruamel-yaml
@@ -1237,11 +1239,12 @@ semantic-version==2.10.0
# edx-drf-extensions
shapely==2.0.1
# via -r requirements/edx/base.txt
-simplejson==3.19.1
+simplejson==3.19.2
# via
# -r requirements/edx/base.txt
# sailthru-client
# super-csv
+ # xblock
# xblock-utils
six==1.16.0
# via
@@ -1283,7 +1286,7 @@ smmap==5.0.1
# via gitdb
snowballstemmer==2.2.0
# via sphinx
-snowflake-connector-python==3.2.0
+snowflake-connector-python==3.2.1
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1298,7 +1301,7 @@ social-auth-core==4.3.0
# -r requirements/edx/base.txt
# edx-auth-backends
# social-auth-app-django
-sorl-thumbnail==12.9.0
+sorl-thumbnail==12.10.0
# via
# -r requirements/edx/base.txt
# openedx-django-wiki
@@ -1377,7 +1380,7 @@ text-unidecode==1.3
# via
# -r requirements/edx/base.txt
# python-slugify
-tinycss2==1.1.1
+tinycss2==1.2.1
# via
# -r requirements/edx/base.txt
# bleach
@@ -1468,7 +1471,7 @@ wrapt==1.15.0
# via
# -r requirements/edx/base.txt
# deprecated
-xblock[django]==1.8.0
+xblock[django]==1.8.1
# via
# -r requirements/edx/base.txt
# acid-xblock
@@ -1489,7 +1492,7 @@ xblock-google-drive==0.4.0
# via -r requirements/edx/base.txt
xblock-poll==1.13.0
# via -r requirements/edx/base.txt
-xblock-utils==3.4.1
+xblock-utils==4.0.0
# via
# -r requirements/edx/base.txt
# done-xblock
diff --git a/requirements/edx/github.in b/requirements/edx/github.in
index d36b6f9629..ea6d47eec8 100644
--- a/requirements/edx/github.in
+++ b/requirements/edx/github.in
@@ -86,3 +86,7 @@
##############################################################################
# ... add dependencies here
+
+# django42 support PR merged but new release is pending.
+# https://github.com/openedx/edx-platform/issues/33431
+-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in
index fb0307ba43..1c727d2f9c 100644
--- a/requirements/edx/kernel.in
+++ b/requirements/edx/kernel.in
@@ -81,7 +81,8 @@ edx-name-affirmation
edx-opaque-keys
edx-organizations
edx-proctoring>=2.0.1
-edx-proctoring-proctortrack==1.2.1 # Intentionally and permanently pinned to ensure code changes are reviewed
+# using hash to support django42
+# edx-proctoring-proctortrack==1.2.1 # Intentionally and permanently pinned to ensure code changes are reviewed
edx-rest-api-client
edx-search
edx-submissions
@@ -157,5 +158,4 @@ user-util # Functionality for retiring users (GDPR com
webob
web-fragments # Provides the ability to render fragments of web pages
XBlock[django] # Courseware component architecture
-xblock-utils # Provides utilities used by the Discussion XBlock
xss-utils # https://github.com/edx/edx-platform/pull/20633 Fix XSS via Translations
diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt
index fb696264ac..31dcbbf984 100644
--- a/requirements/edx/semgrep.txt
+++ b/requirements/edx/semgrep.txt
@@ -70,15 +70,15 @@ requests==2.31.0
# via semgrep
rich==13.6.0
# via semgrep
-rpds-py==0.10.3
+rpds-py==0.10.4
# via
# jsonschema
# referencing
-ruamel-yaml==0.17.33
+ruamel-yaml==0.17.35
# via semgrep
-ruamel-yaml-clib==0.2.7
+ruamel-yaml-clib==0.2.8
# via ruamel-yaml
-semgrep==1.42.0
+semgrep==1.43.0
# via -r requirements/edx/semgrep.in
tomli==2.0.1
# via semgrep
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 5dbcd9c8d8..b43ae8bb3e 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -4,9 +4,11 @@
#
# make upgrade
#
+-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
+ # via -r requirements/edx/base.txt
acid-xblock==0.2.1
# via -r requirements/edx/base.txt
-aiohttp==3.8.5
+aiohttp==3.8.6
# via
# -r requirements/edx/base.txt
# geoip2
@@ -29,7 +31,7 @@ aniso8601==9.0.1
# via
# -r requirements/edx/base.txt
# edx-tincan-py35
-annotated-types==0.5.0
+annotated-types==0.6.0
# via pydantic
anyio==3.7.1
# via
@@ -95,7 +97,7 @@ billiard==4.1.0
# via
# -r requirements/edx/base.txt
# celery
-bleach[css]==6.0.0
+bleach[css]==6.1.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -108,13 +110,13 @@ boto==2.39.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-boto3==1.28.58
+boto3==1.28.62
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.31.58
+botocore==1.31.62
# via
# -r requirements/edx/base.txt
# boto3
@@ -256,7 +258,7 @@ dill==0.3.7
# via pylint
distlib==0.3.7
# via virtualenv
-django==3.2.21
+django==3.2.22
# via
# -c requirements/edx/../common_constraints.txt
# -r requirements/edx/base.txt
@@ -525,7 +527,7 @@ edx-auth-backends==4.2.0
# via
# -r requirements/edx/base.txt
# openedx-blockstore
-edx-braze-client==0.1.7
+edx-braze-client==0.1.8
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -569,7 +571,7 @@ edx-django-utils==5.7.0
# openedx-blockstore
# ora2
# super-csv
-edx-drf-extensions==8.10.0
+edx-drf-extensions==8.12.0
# via
# -r requirements/edx/base.txt
# edx-completion
@@ -581,15 +583,15 @@ edx-drf-extensions==8.10.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.5.0
+edx-enterprise==4.6.7
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.5.0
# via -r requirements/edx/base.txt
-edx-event-bus-redis==0.3.1
+edx-event-bus-redis==0.3.2
# via -r requirements/edx/base.txt
-edx-i18n-tools==1.2.0
+edx-i18n-tools==1.3.0
# via
# -r requirements/edx/base.txt
# -r requirements/edx/testing.in
@@ -621,8 +623,6 @@ edx-proctoring==4.16.1
# via
# -r requirements/edx/base.txt
# edx-proctoring-proctortrack
-edx-proctoring-proctortrack==1.2.1
- # via -r requirements/edx/base.txt
edx-rbac==1.8.0
# via
# -r requirements/edx/base.txt
@@ -661,7 +661,7 @@ edx-when==2.4.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
-edxval==2.4.3
+edxval==2.4.4
# via -r requirements/edx/base.txt
elasticsearch==7.13.4
# via
@@ -687,11 +687,11 @@ execnet==2.0.2
# via pytest-xdist
factory-boy==3.3.0
# via -r requirements/edx/testing.in
-faker==19.6.2
+faker==19.9.0
# via factory-boy
fastapi==0.103.2
# via pact-python
-fastavro==1.8.3
+fastavro==1.8.4
# via
# -r requirements/edx/base.txt
# openedx-events
@@ -867,6 +867,7 @@ lti-consumer-xblock==9.6.1
lxml==4.9.3
# via
# -r requirements/edx/base.txt
+ # edx-i18n-tools
# edxval
# lti-consumer-xblock
# olxcleaner
@@ -883,6 +884,7 @@ mako==1.2.4
# -r requirements/edx/base.txt
# acid-xblock
# lti-consumer-xblock
+ # xblock
# xblock-google-drive
# xblock-utils
markdown==3.3.7
@@ -978,7 +980,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/base.txt
openedx-django-wiki==2.0.3
# via -r requirements/edx/base.txt
-openedx-events==8.8.0
+openedx-events==9.0.0
# via
# -r requirements/edx/base.txt
# edx-event-bus-kafka
@@ -987,7 +989,7 @@ openedx-filters==1.6.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
-openedx-learning==0.1.7
+openedx-learning==0.2.5
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -995,7 +997,7 @@ openedx-mongodbproxy==0.2.0
# via -r requirements/edx/base.txt
optimizely-sdk==4.1.1
# via -r requirements/edx/base.txt
-ora2==5.5.3
+ora2==5.5.5
# via -r requirements/edx/base.txt
oscrypto==1.3.0
# via
@@ -1050,7 +1052,7 @@ pkgutil-resolve-name==1.3.10
# via
# -r requirements/edx/base.txt
# jsonschema
-platformdirs==3.8.1
+platformdirs==3.11.0
# via
# -r requirements/edx/base.txt
# pylint
@@ -1079,7 +1081,7 @@ psutil==5.9.5
# pytest-xdist
py==1.11.0
# via tox
-py2neo==2021.2.3
+py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -1247,7 +1249,7 @@ python3-openid==3.2.0 ; python_version >= "3"
# via
# -r requirements/edx/base.txt
# social-auth-core
-python3-saml==1.15.0
+python3-saml==1.16.0
# via -r requirements/edx/base.txt
pytz==2022.7.1
# via
@@ -1295,7 +1297,7 @@ referencing==0.30.2
# -r requirements/edx/base.txt
# jsonschema
# jsonschema-specifications
-regex==2023.8.8
+regex==2023.10.3
# via
# -r requirements/edx/base.txt
# nltk
@@ -1329,16 +1331,16 @@ requests-oauthlib==1.3.1
# social-auth-core
rfc3986[idna2008]==1.5.0
# via httpx
-rpds-py==0.10.3
+rpds-py==0.10.4
# via
# -r requirements/edx/base.txt
# jsonschema
# referencing
-ruamel-yaml==0.17.33
+ruamel-yaml==0.17.35
# via
# -r requirements/edx/base.txt
# drf-yasg
-ruamel-yaml-clib==0.2.7
+ruamel-yaml-clib==0.2.8
# via
# -r requirements/edx/base.txt
# ruamel-yaml
@@ -1368,11 +1370,12 @@ semantic-version==2.10.0
# edx-drf-extensions
shapely==2.0.1
# via -r requirements/edx/base.txt
-simplejson==3.19.1
+simplejson==3.19.2
# via
# -r requirements/edx/base.txt
# sailthru-client
# super-csv
+ # xblock
# xblock-utils
singledispatch==4.1.0
# via -r requirements/edx/testing.in
@@ -1419,7 +1422,7 @@ sniffio==1.3.0
# anyio
# httpcore
# httpx
-snowflake-connector-python==3.2.0
+snowflake-connector-python==3.2.1
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1434,7 +1437,7 @@ social-auth-core==4.3.0
# -r requirements/edx/base.txt
# edx-auth-backends
# social-auth-app-django
-sorl-thumbnail==12.9.0
+sorl-thumbnail==12.10.0
# via
# -r requirements/edx/base.txt
# openedx-django-wiki
@@ -1480,7 +1483,7 @@ text-unidecode==1.3
# via
# -r requirements/edx/base.txt
# python-slugify
-tinycss2==1.1.1
+tinycss2==1.2.1
# via
# -r requirements/edx/base.txt
# bleach
@@ -1565,7 +1568,7 @@ vine==5.0.0
# amqp
# celery
# kombu
-virtualenv==20.24.1
+virtualenv==20.24.5
# via tox
voluptuous==0.13.1
# via
@@ -1604,7 +1607,7 @@ wrapt==1.15.0
# -r requirements/edx/base.txt
# astroid
# deprecated
-xblock[django]==1.8.0
+xblock[django]==1.8.1
# via
# -r requirements/edx/base.txt
# acid-xblock
@@ -1625,7 +1628,7 @@ xblock-google-drive==0.4.0
# via -r requirements/edx/base.txt
xblock-poll==1.13.0
# via -r requirements/edx/base.txt
-xblock-utils==3.4.1
+xblock-utils==4.0.0
# via
# -r requirements/edx/base.txt
# done-xblock
diff --git a/scripts/copy-node-modules.sh b/scripts/copy-node-modules.sh
index db997da957..f49514b6de 100755
--- a/scripts/copy-node-modules.sh
+++ b/scripts/copy-node-modules.sh
@@ -26,28 +26,33 @@ log ( ) {
echo -e "${COL_LOG}$* $COL_OFF"
}
+# Print a command (prefixed with '+') and then run it, to aid in debugging.
+# This is just like `set -x`, except that `set -x` prints to STDERR, which is hidden
+# by GitHub Actions logs. This functions prints to STDOUT, which is visible.
+log_and_run ( ) {
+ log "+$*"
+ "$@" # Joins arguments to form a command (quoting as necessary) and runs the command.
+}
+
log "====================================================================================="
log "Copying required assets from node_modules..."
log "-------------------------------------------------------------------------------"
-# Start echoing all commands back to user for ease of debugging.
-set -x
-
log "Ensuring vendor directories exist..."
-mkdir -p "$vendor_js"
-mkdir -p "$vendor_css"
+log_and_run mkdir -p "$vendor_js"
+log_and_run mkdir -p "$vendor_css"
log "Copying studio-frontend JS & CSS from node_modules into vendor directores..."
while read -r -d $'\0' src_file ; do
if [[ "$src_file" = *.css ]] || [[ "$src_file" = *.css.map ]] ; then
- cp --force "$src_file" "$vendor_css"
+ log_and_run cp --force "$src_file" "$vendor_css"
else
- cp --force "$src_file" "$vendor_js"
+ log_and_run cp --force "$src_file" "$vendor_js"
fi
done < <(find "$node_modules/@edx/studio-frontend/dist" -type f -print0)
log "Copying certain JS modules from node_modules into vendor directory..."
-cp --force \
+log_and_run cp --force \
"$node_modules/backbone.paginator/lib/backbone.paginator.js" \
"$node_modules/backbone/backbone.js" \
"$node_modules/bootstrap/dist/js/bootstrap.bundle.js" \
@@ -66,8 +71,8 @@ cp --force \
log "Copying certain JS developer modules into vendor directory..."
if [[ "${NODE_ENV:-production}" = development ]] ; then
- cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js"
- cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js"
+ log_and_run cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js"
+ log_and_run cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js"
else
# TODO: https://github.com/openedx/edx-platform/issues/31768
# In the old implementation of this scipt (pavelib/assets.py), these two
@@ -77,13 +82,10 @@ else
# However, in the future, it would be good to only copy them for dev
# builds. Furthermore, these libraries should not be `npm install`ed
# into prod builds in the first place.
- cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" || true # "|| true" means "tolerate errors"; in this case,
- cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" || true # that's "tolerate if these files don't exist."
+ log_and_run cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" || true # "|| true" means "tolerate errors"; in this case,
+ log_and_run cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" || true # that's "tolerate if these files don't exist."
fi
-# Done echoing.
-set +x
-
log "-------------------------------------------------------------------------------"
log " Done copying required assets from node_modules."
log "====================================================================================="
diff --git a/webpack-config/file-lists.js b/webpack-config/file-lists.js
index ddb9cf4f78..7167a6f5dd 100644
--- a/webpack-config/file-lists.js
+++ b/webpack-config/file-lists.js
@@ -99,9 +99,6 @@ module.exports = {
'../openedx/features/course_search/static/course_search/js/views/dashboard_search_results_view.js'
),
path.resolve(__dirname, '../openedx/features/course_search/static/course_search/js/views/search_results_view.js'),
- path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js'),
- path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js'),
- path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_view.js'),
path.resolve(
__dirname,
'../openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js'
diff --git a/xmodule/capa/safe_exec/tests/test_safe_exec.py b/xmodule/capa/safe_exec/tests/test_safe_exec.py
index 0f6429ec5e..fa12e4f699 100644
--- a/xmodule/capa/safe_exec/tests/test_safe_exec.py
+++ b/xmodule/capa/safe_exec/tests/test_safe_exec.py
@@ -18,6 +18,7 @@ from django.test import override_settings
from six import unichr
from six.moves import range
+from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa.safe_exec import safe_exec, update_hash
from xmodule.capa.safe_exec.remote_exec import is_codejail_rest_service_enabled
@@ -81,17 +82,30 @@ class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-
class TestSafeOrNot(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
+
+ @skip_unless_lms
def test_cant_do_something_forbidden(self):
'''
Demonstrates that running unsafe code inside the code jail
throws SafeExecException, protecting the calling process.
+
+ This test generally is skipped in CI due to its complex setup. That said, we recommend that devs who are
+ hacking on CodeJail or advanced CAPA in any significant way take the time to make sure it passes locally.
+ See either:
+ * in-platform setup: https://github.com/openedx/edx-platform/blob/master/xmodule/capa/safe_exec/README.rst
+ * remote setup (using Tutor): https://github.com/eduNEXT/tutor-contrib-codejail
+
+ Note on @skip_unless_lms:
+ This test can also be run in a CMS context, but that's giving us trouble in CI right now (the skip logic isn't
+ working). So, if you're running this locally, feel free to remove @skip_unless_lms and run it against CMS too.
'''
- # Can't test for forbiddenness if CodeJail isn't configured for python.
+ # If in-platform codejail isn't configured...
if not jail_code.is_configured("python"):
- # Can't test for forbiddenness if CodeJail rest service isn't enabled.
- # Remote codejailservice must be running, see https://github.com/eduNEXT/tutor-contrib-codejail/
+ # ...AND if remote codejail isn't configured...
if not is_codejail_rest_service_enabled():
+
+ # ...then skip this test.
pytest.skip(reason="Local or remote codejail has to be configured and enabled to run this test.")
g = {}
diff --git a/xmodule/capa/tests/test_xqueue_interface.py b/xmodule/capa/tests/test_xqueue_interface.py
index 5fd913a15e..819fd73c79 100644
--- a/xmodule/capa/tests/test_xqueue_interface.py
+++ b/xmodule/capa/tests/test_xqueue_interface.py
@@ -8,9 +8,11 @@ from django.test.utils import override_settings
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xblock.fields import ScopeIds
+from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa.xqueue_interface import XQueueInterface, XQueueService
+@skip_unless_lms
class XQueueServiceTest(TestCase):
"""Test the XQueue service methods."""
def setUp(self):
diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py
index 67d5fd58c2..7b58b5aa9a 100644
--- a/xmodule/capa_block.py
+++ b/xmodule/capa_block.py
@@ -504,6 +504,11 @@ class ProblemBlock(
ProblemBlock.markdown,
ProblemBlock.use_latex_compiler,
ProblemBlock.show_correctness,
+
+ # Temporarily remove the ability to see MATLAB API key in Studio, as
+ # a pre-cursor to removing it altogether.
+ # https://github.com/openedx/public-engineering/issues/192
+ ProblemBlock.matlab_api_key,
])
return non_editable_fields
diff --git a/xmodule/course_block.py b/xmodule/course_block.py
index bb08e252b0..ae6d88b7b7 100644
--- a/xmodule/course_block.py
+++ b/xmodule/course_block.py
@@ -619,14 +619,6 @@ class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring
# Ensure that courses imported from XML keep their image
default="images_course_image.jpg"
)
- issue_badges = Boolean(
- display_name=_("Issue Open Badges"),
- help=_(
- "Issue Open Badges badges for this course. Badges are generated when certificates are created."
- ),
- scope=Scope.settings,
- default=True
- )
## Course level Certificate Name overrides.
cert_name_short = String(
help=_(
diff --git a/xmodule/discussion_block.py b/xmodule/discussion_block.py
index b5c5c01907..943c6359dd 100644
--- a/xmodule/discussion_block.py
+++ b/xmodule/discussion_block.py
@@ -12,8 +12,8 @@ from web_fragments.fragment import Fragment
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock
from xblock.fields import UNIQUE_ID, Scope, String
-from xblockutils.resources import ResourceLoader
-from xblockutils.studio_editable import StudioEditableXBlockMixin
+from xblock.utils.resources import ResourceLoader
+from xblock.utils.studio_editable import StudioEditableXBlockMixin
from lms.djangoapps.discussion.django_comment_client.permissions import has_permission
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider
diff --git a/xmodule/modulestore/tests/django_utils.py b/xmodule/modulestore/tests/django_utils.py
index fcc01763fd..952a88800f 100644
--- a/xmodule/modulestore/tests/django_utils.py
+++ b/xmodule/modulestore/tests/django_utils.py
@@ -369,7 +369,7 @@ class ModuleStoreTestUsersMixin():
"""
A mixin to help manage test users.
"""
- TEST_PASSWORD = 'test'
+ TEST_PASSWORD = 'Password1234'
def create_user_for_course(self, course, user_type=CourseUserType.ENROLLED):
"""
diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py
index b511fe403e..ab94028fc9 100644
--- a/xmodule/tests/test_capa_block.py
+++ b/xmodule/tests/test_capa_block.py
@@ -29,6 +29,7 @@ from xblock.fields import ScopeIds
from xblock.scorable import Score
import xmodule
+from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.capa import responsetypes
from xmodule.capa.correctmap import CorrectMap
from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError
@@ -182,6 +183,7 @@ if submission[0] == '':
@ddt.ddt
+@skip_unless_lms
class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring
def setUp(self):
@@ -2898,6 +2900,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi
# ignore quotes
+@skip_unless_lms
class ProblemCheckTrackingTest(unittest.TestCase):
"""
Ensure correct tracking information is included in events emitted during problem checks.
diff --git a/xmodule/tests/test_library_tools.py b/xmodule/tests/test_library_tools.py
index 950307b696..e2daa723cc 100644
--- a/xmodule/tests/test_library_tools.py
+++ b/xmodule/tests/test_library_tools.py
@@ -1,9 +1,10 @@
"""
-Tests for library tools service.
+Tests for library tools service (only used by CMS)
"""
from unittest.mock import patch
from opaque_keys.edx.keys import UsageKey
+from openedx.core.djangolib.testing.utils import skip_unless_cms
from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
from openedx.core.djangoapps.xblock.api import load_block
@@ -13,6 +14,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
+@skip_unless_cms
class LibraryToolsServiceTest(MixedSplitTestCase):
"""
Tests for library service.
@@ -40,6 +42,7 @@ class LibraryToolsServiceTest(MixedSplitTestCase):
assert mock_get_library_summaries.called
+@skip_unless_cms
class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
"""
Tests for LibraryToolsService which interact with blockstore-based content libraries
@@ -63,16 +66,16 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
CourseInstructorRole(course.id).add_users(self.user)
# Add Source from library block to the course
- sourced_block = self.make_block("library_sourced", course, user_id=self.user.id)
+ lc_block = self.make_block("library_content", course, user_id=self.user.id)
# Import the unit block from the library to the course
- self.tools.import_from_blockstore(sourced_block, [unit_block_id])
+ self.tools.import_from_blockstore(lc_block, [unit_block_id])
# Verify imported block with its children
- assert len(sourced_block.children) == 1
- assert sourced_block.children[0].category == 'unit'
+ assert len(lc_block.children) == 1
+ assert lc_block.children[0].category == 'unit'
- imported_unit_block = self.store.get_item(sourced_block.children[0])
+ imported_unit_block = self.store.get_item(lc_block.children[0])
assert len(imported_unit_block.children) == 1
assert imported_unit_block.children[0].category == 'html'
@@ -86,10 +89,10 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
# Check that reimporting updates the target block
self._set_library_block_olx(html_block_id, 'Foo bar')
- self.tools.import_from_blockstore(sourced_block, [unit_block_id])
+ self.tools.import_from_blockstore(lc_block, [unit_block_id])
- assert len(sourced_block.children) == 1
- imported_unit_block = self.store.get_item(sourced_block.children[0])
+ assert len(lc_block.children) == 1
+ imported_unit_block = self.store.get_item(lc_block.children[0])
assert len(imported_unit_block.children) == 1
imported_html_block = self.store.get_item(imported_unit_block.children[0])
assert 'Hello world' not in imported_html_block.data