diff --git a/nau_openedx_extensions/admin.py b/nau_openedx_extensions/admin.py index eff1aa35..c17e6276 100644 --- a/nau_openedx_extensions/admin.py +++ b/nau_openedx_extensions/admin.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals +from nau_openedx_extensions.course_filters.admin import * # pylint: disable=wildcard-import from nau_openedx_extensions.custom_registration_form.admin import * # pylint: disable=wildcard-import from nau_openedx_extensions.enrollment_by_domain.admin import * # pylint: disable=wildcard-import from nau_openedx_extensions.partner_integration.admin import * # pylint: disable=wildcard-import diff --git a/nau_openedx_extensions/course_filters/__init__.py b/nau_openedx_extensions/course_filters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nau_openedx_extensions/course_filters/admin.py b/nau_openedx_extensions/course_filters/admin.py new file mode 100644 index 00000000..b78ca2ac --- /dev/null +++ b/nau_openedx_extensions/course_filters/admin.py @@ -0,0 +1,29 @@ +""" +Admin configuration for course_filters. +""" + +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import NauCourseFilter + + +@admin.register(NauCourseFilter) +class NauCourseFilterAdmin(admin.ModelAdmin): + """Admin for NauCourseFilter model.""" + + list_display = ("course_id", "filter_type", "created_at", "updated_at") + list_filter = ("filter_type", "created_at") + search_fields = ("course_id", "filter_type") + readonly_fields = ("created_at", "updated_at") + ordering = ("course_id", "filter_type") + + fieldsets = ( + (None, { + "fields": ("course_id", "filter_type"), + }), + (_("Timestamps"), { + "fields": ("created_at", "updated_at"), + "classes": ("collapse",), + }), + ) diff --git a/nau_openedx_extensions/course_filters/handlers.py b/nau_openedx_extensions/course_filters/handlers.py new file mode 100644 index 00000000..2056b889 --- /dev/null +++ b/nau_openedx_extensions/course_filters/handlers.py @@ -0,0 +1,35 @@ +""" +Signal handlers for course_filters. + +Connects to the internal CMS course_published signal so that +NauCourseFilter rows are kept in sync whenever a course is saved +in Studio (including Advanced Settings saves). +""" + +import logging + +from nau_openedx_extensions.course_filters.sync import sync_course_filters_for_course + +log = logging.getLogger(__name__) + + +def course_published_handler(course_key, **kwargs): # pylint: disable=unused-argument + """ + Handler for the CMS course_published signal. + + Syncs NauCourseFilter rows for the published course. Wrapped in a broad + try/except so this handler never raises and never blocks the course + publishing flow. + """ + try: + log.info("course_filters: received course_published signal for %s", course_key) + result = sync_course_filters_for_course(course_key) + log.info( + "course_filters: sync completed for %s — created=%d, deleted=%d, unchanged=%d", + course_key, + result["created"], + result["deleted"], + result["unchanged"], + ) + except Exception: # pylint: disable=broad-except + log.exception("course_filters: unexpected error in course_published_handler for %s", course_key) diff --git a/nau_openedx_extensions/course_filters/models.py b/nau_openedx_extensions/course_filters/models.py new file mode 100644 index 00000000..ad33526b --- /dev/null +++ b/nau_openedx_extensions/course_filters/models.py @@ -0,0 +1,45 @@ +""" +Models for course_filters app. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class NauCourseFilter(models.Model): + """ + Stores which enrollment/certificate filters are active for a given course. + + Each row represents one active filter on one course. The table is kept in + sync with MongoDB's other_course_settings on every course publish so that + the nau-database-exporter can query filter status from MySQL without + touching MongoDB. + """ + + course_id = models.CharField( + max_length=255, + help_text=_("Course key string (e.g. course-v1:ORG+ID+Run)"), + verbose_name=_("Course ID"), + db_index=True, + ) + filter_type = models.CharField( + max_length=255, + help_text=_( + "Filter key name as stored in other_course_settings " + "(e.g. filter_enrollment_by_domain_list, filter_enrollment_require_nif, " + "certificate_require_portuguese_citizen_card)" + ), + verbose_name=_("Filter Type"), + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + app_label = "nau_openedx_extensions" + verbose_name = _("NAU Course Filter") + verbose_name_plural = _("NAU Course Filters") + unique_together = [("course_id", "filter_type")] + ordering = ["course_id", "filter_type"] + + def __str__(self): + return f"{self.course_id}: {self.filter_type}" diff --git a/nau_openedx_extensions/course_filters/sync.py b/nau_openedx_extensions/course_filters/sync.py new file mode 100644 index 00000000..26a9d33e --- /dev/null +++ b/nau_openedx_extensions/course_filters/sync.py @@ -0,0 +1,79 @@ +""" +Sync logic for NauCourseFilter. + +This module is the single source of truth for reading other_course_settings +from MongoDB and writing the active filters to MySQL. It is used by both the +course_published signal handler (per-course, on save) and the backfill +management command (all courses, on demand). +""" + +import logging + +from django.conf import settings + +from nau_openedx_extensions.edxapp_wrapper.course_module import get_other_course_settings + +log = logging.getLogger(__name__) + +# Default filter keys to track. Extend via settings.NAU_COURSE_FILTER_KEYS. +_DEFAULT_FILTER_KEYS = ( + "filter_enrollment_by_domain_list", + "filter_enrollment_require_nif", + "certificate_require_portuguese_citizen_card", +) + + +def get_known_filter_keys(): + """Return the tuple of filter keys to track, from settings or defaults.""" + return tuple(getattr(settings, "NAU_COURSE_FILTER_KEYS", _DEFAULT_FILTER_KEYS)) + + +def sync_course_filters_for_course(course_key): + """ + Synchronize NauCourseFilter rows for a single course. + + Reads other_course_settings from MongoDB, determines which known filter + keys have a truthy value, then: + - creates rows for newly active filters, + - deletes rows for filters that are no longer active. + + Returns a dict with keys 'created', 'deleted', 'unchanged' counts. + Raises no exceptions — errors are logged so callers (e.g. signal handlers) + are never blocked. + """ + from nau_openedx_extensions.course_filters.models import NauCourseFilter # pylint: disable=import-outside-toplevel + + course_id_str = str(course_key) + result = {"created": 0, "deleted": 0, "unchanged": 0} + + try: + other_course_settings = get_other_course_settings(course_key) + settings_values = other_course_settings.get("value", {}) + + known_keys = get_known_filter_keys() + active_filters = {key for key in known_keys if settings_values.get(key)} + + existing_qs = NauCourseFilter.objects.filter(course_id=course_id_str) + existing_filters = set(existing_qs.values_list("filter_type", flat=True)) + + to_create = active_filters - existing_filters + to_delete = existing_filters - active_filters + + if to_create: + NauCourseFilter.objects.bulk_create( + [NauCourseFilter(course_id=course_id_str, filter_type=ft) for ft in to_create] + ) + result["created"] = len(to_create) + log.info("course_filters: created %d filter(s) for %s: %s", len(to_create), course_id_str, to_create) + + if to_delete: + deleted_count, _ = existing_qs.filter(filter_type__in=to_delete).delete() + result["deleted"] = deleted_count + log.info("course_filters: deleted %d filter(s) for %s: %s", deleted_count, course_id_str, to_delete) + + result["unchanged"] = len(active_filters & existing_filters) + + except Exception: # pylint: disable=broad-except + log.exception("course_filters: error syncing filters for course %s", course_id_str) + + return result diff --git a/nau_openedx_extensions/course_filters/tests/__init__.py b/nau_openedx_extensions/course_filters/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nau_openedx_extensions/course_filters/tests/test_handlers.py b/nau_openedx_extensions/course_filters/tests/test_handlers.py new file mode 100644 index 00000000..36fedeee --- /dev/null +++ b/nau_openedx_extensions/course_filters/tests/test_handlers.py @@ -0,0 +1,60 @@ +""" +Unit tests for course_filters.handlers module. +""" + +from unittest.mock import patch + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey + +from nau_openedx_extensions.course_filters.handlers import course_published_handler + +HANDLERS_PATH = "nau_openedx_extensions.course_filters.handlers" + +COURSE_ID = "course-v1:Demo+DemoX+Demo_Course" + + +class CoursePublishedHandlerTest(TestCase): + """Tests for course_published_handler().""" + + def setUp(self): + self.course_key = CourseKey.from_string(COURSE_ID) + + @patch(f"{HANDLERS_PATH}.sync_course_filters_for_course") + @patch(f"{HANDLERS_PATH}.log") + def test_handler_calls_sync_and_logs(self, mock_log, mock_sync): + """ + Handler calls sync_course_filters_for_course and logs the result. + """ + mock_sync.return_value = {"created": 2, "deleted": 0, "unchanged": 1} + + course_published_handler(course_key=self.course_key) + + mock_sync.assert_called_once_with(self.course_key) + mock_log.info.assert_called() + + @patch(f"{HANDLERS_PATH}.sync_course_filters_for_course") + @patch(f"{HANDLERS_PATH}.log") + def test_handler_does_not_raise_on_sync_error(self, mock_log, mock_sync): + """ + If sync_course_filters_for_course raises (which it shouldn't by design), the handler + does not propagate the exception so course publishing is never blocked. + """ + mock_sync.side_effect = Exception("Unexpected failure") + + try: + course_published_handler(course_key=self.course_key) + except Exception: # pylint: disable=broad-except + self.fail("course_published_handler raised an exception unexpectedly") + + @patch(f"{HANDLERS_PATH}.sync_course_filters_for_course") + @patch(f"{HANDLERS_PATH}.log") + def test_handler_passes_extra_kwargs(self, mock_log, mock_sync): + """ + Handler accepts **kwargs from the signal dispatcher without errors. + """ + mock_sync.return_value = {"created": 0, "deleted": 0, "unchanged": 0} + + course_published_handler(course_key=self.course_key, sender=None, signal=None) + + mock_sync.assert_called_once_with(self.course_key) diff --git a/nau_openedx_extensions/course_filters/tests/test_sync.py b/nau_openedx_extensions/course_filters/tests/test_sync.py new file mode 100644 index 00000000..f7a01618 --- /dev/null +++ b/nau_openedx_extensions/course_filters/tests/test_sync.py @@ -0,0 +1,206 @@ +""" +Unit tests for course_filters.sync module. +""" + +from unittest.mock import patch + +from django.test import TestCase, override_settings +from opaque_keys.edx.keys import CourseKey + +from nau_openedx_extensions.course_filters.models import NauCourseFilter +from nau_openedx_extensions.course_filters.sync import get_known_filter_keys, sync_course_filters_for_course + +SYNC_PATH = "nau_openedx_extensions.course_filters.sync" + +COURSE_ID = "course-v1:Demo+DemoX+Demo_Course" + + +class GetKnownFilterKeysTest(TestCase): + """Tests for get_known_filter_keys().""" + + def test_returns_defaults_when_setting_absent(self): + keys = get_known_filter_keys() + self.assertIn("filter_enrollment_by_domain_list", keys) + self.assertIn("filter_enrollment_require_nif", keys) + self.assertIn("certificate_require_portuguese_citizen_card", keys) + + @override_settings(NAU_COURSE_FILTER_KEYS=("custom_filter_a", "custom_filter_b")) + def test_returns_custom_keys_from_settings(self): + keys = get_known_filter_keys() + self.assertEqual(keys, ("custom_filter_a", "custom_filter_b")) + + +class SyncCourseFiltersForCourseTest(TestCase): + """Tests for sync_course_filters_for_course().""" + + def setUp(self): + self.course_key = CourseKey.from_string(COURSE_ID) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_no_filters_active_creates_no_rows(self, mock_get_settings): + """ + When other_course_settings has no known filter keys, no rows are created. + """ + mock_get_settings.return_value = {"value": {}} + + result = sync_course_filters_for_course(self.course_key) + + self.assertEqual(NauCourseFilter.objects.filter(course_id=COURSE_ID).count(), 0) + self.assertEqual(result["created"], 0) + self.assertEqual(result["deleted"], 0) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_multiple_active_filters_creates_rows(self, mock_get_settings): + """ + When multiple known filter keys have truthy values, rows are created for each. + """ + mock_get_settings.return_value = { + "value": { + "filter_enrollment_require_nif": True, + "certificate_require_portuguese_citizen_card": True, + } + } + + result = sync_course_filters_for_course(self.course_key) + + filters = NauCourseFilter.objects.filter(course_id=COURSE_ID) + filter_types = set(filters.values_list("filter_type", flat=True)) + self.assertIn("filter_enrollment_require_nif", filter_types) + self.assertIn("certificate_require_portuguese_citizen_card", filter_types) + self.assertEqual(result["created"], 2) + self.assertEqual(result["deleted"], 0) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_filter_with_domain_list_creates_row(self, mock_get_settings): + """ + A non-empty list value for filter_enrollment_by_domain_list is truthy and creates a row. + """ + mock_get_settings.return_value = { + "value": { + "filter_enrollment_by_domain_list": ["example.com", "test.org"], + } + } + + result = sync_course_filters_for_course(self.course_key) + + self.assertTrue( + NauCourseFilter.objects.filter( + course_id=COURSE_ID, filter_type="filter_enrollment_by_domain_list" + ).exists() + ) + self.assertEqual(result["created"], 1) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_filter_removal_deletes_rows(self, mock_get_settings): + """ + Rows for filters that are no longer active in other_course_settings are deleted. + """ + NauCourseFilter.objects.create( + course_id=COURSE_ID, filter_type="filter_enrollment_require_nif" + ) + NauCourseFilter.objects.create( + course_id=COURSE_ID, filter_type="certificate_require_portuguese_citizen_card" + ) + + mock_get_settings.return_value = {"value": {}} + + result = sync_course_filters_for_course(self.course_key) + + self.assertEqual(NauCourseFilter.objects.filter(course_id=COURSE_ID).count(), 0) + self.assertEqual(result["deleted"], 2) + self.assertEqual(result["created"], 0) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_partial_update_adds_and_removes(self, mock_get_settings): + """ + When some filters are added and some removed in the same publish, both operations happen. + """ + NauCourseFilter.objects.create( + course_id=COURSE_ID, filter_type="filter_enrollment_require_nif" + ) + + mock_get_settings.return_value = { + "value": { + "certificate_require_portuguese_citizen_card": True, + } + } + + result = sync_course_filters_for_course(self.course_key) + + filter_types = set( + NauCourseFilter.objects.filter(course_id=COURSE_ID).values_list("filter_type", flat=True) + ) + self.assertIn("certificate_require_portuguese_citizen_card", filter_types) + self.assertNotIn("filter_enrollment_require_nif", filter_types) + self.assertEqual(result["created"], 1) + self.assertEqual(result["deleted"], 1) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_unchanged_filters_are_counted(self, mock_get_settings): + """ + Filters that are active both before and after the sync are reported as unchanged. + """ + NauCourseFilter.objects.create( + course_id=COURSE_ID, filter_type="filter_enrollment_require_nif" + ) + + mock_get_settings.return_value = { + "value": { + "filter_enrollment_require_nif": True, + } + } + + result = sync_course_filters_for_course(self.course_key) + + self.assertEqual(result["created"], 0) + self.assertEqual(result["deleted"], 0) + self.assertEqual(result["unchanged"], 1) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_falsy_filter_value_creates_no_row(self, mock_get_settings): + """ + Filter keys with falsy values (False, empty list, empty string) do not create rows. + """ + mock_get_settings.return_value = { + "value": { + "filter_enrollment_require_nif": False, + "filter_enrollment_by_domain_list": [], + "certificate_require_portuguese_citizen_card": "", + } + } + + result = sync_course_filters_for_course(self.course_key) + + self.assertEqual(NauCourseFilter.objects.filter(course_id=COURSE_ID).count(), 0) + self.assertEqual(result["created"], 0) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_exception_in_get_settings_does_not_raise(self, mock_get_settings): + """ + If get_other_course_settings raises, sync_course_filters_for_course catches it and returns + without raising, so the course_published signal handler is never blocked. + """ + mock_get_settings.side_effect = Exception("MongoDB connection failed") + + result = sync_course_filters_for_course(self.course_key) + + self.assertEqual(result, {"created": 0, "deleted": 0, "unchanged": 0}) + self.assertEqual(NauCourseFilter.objects.filter(course_id=COURSE_ID).count(), 0) + + @patch(f"{SYNC_PATH}.get_other_course_settings") + def test_idempotent_on_repeated_calls(self, mock_get_settings): + """ + Calling sync twice with the same settings produces no duplicate rows. + """ + mock_get_settings.return_value = { + "value": { + "filter_enrollment_require_nif": True, + } + } + + sync_course_filters_for_course(self.course_key) + result = sync_course_filters_for_course(self.course_key) + + self.assertEqual(NauCourseFilter.objects.filter(course_id=COURSE_ID).count(), 1) + self.assertEqual(result["created"], 0) + self.assertEqual(result["unchanged"], 1) diff --git a/nau_openedx_extensions/locale/en/LC_MESSAGES/django.mo b/nau_openedx_extensions/locale/en/LC_MESSAGES/django.mo index fce98e25..1c3c9f5a 100644 Binary files a/nau_openedx_extensions/locale/en/LC_MESSAGES/django.mo and b/nau_openedx_extensions/locale/en/LC_MESSAGES/django.mo differ diff --git a/nau_openedx_extensions/locale/en/LC_MESSAGES/django.po b/nau_openedx_extensions/locale/en/LC_MESSAGES/django.po index 6e0e30d9..c0a3dd43 100644 --- a/nau_openedx_extensions/locale/en/LC_MESSAGES/django.po +++ b/nau_openedx_extensions/locale/en/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: equipa@nau.edu.pt\n" -"POT-Creation-Date: 2026-05-12 17:15+0100\n" +"POT-Creation-Date: 2026-04-14 18:29-0500\n" "PO-Revision-Date: 2021-02-15 15:56+0000\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -40,6 +40,39 @@ msgstr "" msgid "You do not have permission to export certificates for this course." msgstr "" +#: nau_openedx_extensions/course_filters/admin.py:25 +#: nau_openedx_extensions/enrollment_by_domain/admin.py:57 +#: nau_openedx_extensions/enrollment_by_domain/admin.py:134 +msgid "Timestamps" +msgstr "" + +#: nau_openedx_extensions/course_filters/models.py:21 +msgid "Course key string (e.g. course-v1:ORG+ID+Run)" +msgstr "" + +#: nau_openedx_extensions/course_filters/models.py:22 +msgid "Course ID" +msgstr "" + +#: nau_openedx_extensions/course_filters/models.py:28 +msgid "" +"Filter key name as stored in other_course_settings (e.g. " +"filter_enrollment_by_domain_list, filter_enrollment_require_nif, " +"certificate_require_portuguese_citizen_card)" +msgstr "" + +#: nau_openedx_extensions/course_filters/models.py:32 +msgid "Filter Type" +msgstr "" + +#: nau_openedx_extensions/course_filters/models.py:39 +msgid "NAU Course Filter" +msgstr "" + +#: nau_openedx_extensions/course_filters/models.py:40 +msgid "NAU Course Filters" +msgstr "" + #: nau_openedx_extensions/custom_registration_form/forms.py:39 msgid "You must read and understood the Privacy Policy" msgstr "" @@ -160,11 +193,6 @@ msgstr "" msgid "Statistics" msgstr "" -#: nau_openedx_extensions/enrollment_by_domain/admin.py:57 -#: nau_openedx_extensions/enrollment_by_domain/admin.py:134 -msgid "Timestamps" -msgstr "" - #: nau_openedx_extensions/enrollment_by_domain/admin.py:72 #: nau_openedx_extensions/enrollment_by_domain/models.py:23 msgid "Description" diff --git a/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.mo b/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.mo index c5a9e7e6..872d5a3e 100644 Binary files a/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.mo and b/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.mo differ diff --git a/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.po b/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.po index d5102713..6ef54b5e 100644 --- a/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.po +++ b/nau_openedx_extensions/locale/pt_PT/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: equipa@nau.edu.pt\n" -"POT-Creation-Date: 2026-05-12 17:15+0100\n" +"POT-Creation-Date: 2026-04-14 18:04-0500\n" "PO-Revision-Date: 2025-07-17 15:32+0100\n" "Last-Translator: Ivo Branco \n" "Language: pt_PT\n" @@ -47,6 +47,42 @@ msgstr "O chave do curso fornecida é inválida." msgid "You do not have permission to export certificates for this course." msgstr "Não tem permissão para exportar certificados deste curso." +#: nau_openedx_extensions/course_filters/admin.py:25 +#: nau_openedx_extensions/enrollment_by_domain/admin.py:57 +#: nau_openedx_extensions/enrollment_by_domain/admin.py:134 +msgid "Timestamps" +msgstr "Registos temporais" + +#: nau_openedx_extensions/course_filters/models.py:21 +msgid "Course key string (e.g. course-v1:ORG+ID+Run)" +msgstr "Chave textual do curso (ex.: course-v1:ORG+ID+Run)" + +#: nau_openedx_extensions/course_filters/models.py:22 +msgid "Course ID" +msgstr "ID do curso" + +#: nau_openedx_extensions/course_filters/models.py:28 +msgid "" +"Filter key name as stored in other_course_settings (e.g. " +"filter_enrollment_by_domain_list, filter_enrollment_require_nif, " +"certificate_require_portuguese_citizen_card)" +msgstr "" +"Nome da chave de filtro tal como em other_course_settings (ex.: " +"filter_enrollment_by_domain_list, filter_enrollment_require_nif, " +"certificate_require_portuguese_citizen_card)" + +#: nau_openedx_extensions/course_filters/models.py:32 +msgid "Filter Type" +msgstr "Tipo de filtro" + +#: nau_openedx_extensions/course_filters/models.py:39 +msgid "NAU Course Filter" +msgstr "Filtro de curso NAU" + +#: nau_openedx_extensions/course_filters/models.py:40 +msgid "NAU Course Filters" +msgstr "Filtros de curso NAU" + #: nau_openedx_extensions/custom_registration_form/forms.py:39 msgid "You must read and understood the Privacy Policy" msgstr "Deverá ler e compreender a Politica de Privacidade" @@ -171,11 +207,6 @@ msgstr "" msgid "Statistics" msgstr "Estatísticas" -#: nau_openedx_extensions/enrollment_by_domain/admin.py:57 -#: nau_openedx_extensions/enrollment_by_domain/admin.py:134 -msgid "Timestamps" -msgstr "Registos temporais" - #: nau_openedx_extensions/enrollment_by_domain/admin.py:72 #: nau_openedx_extensions/enrollment_by_domain/models.py:23 msgid "Description" diff --git a/nau_openedx_extensions/migrations/0012_naucoursefilter.py b/nau_openedx_extensions/migrations/0012_naucoursefilter.py new file mode 100644 index 00000000..0eb46cd0 --- /dev/null +++ b/nau_openedx_extensions/migrations/0012_naucoursefilter.py @@ -0,0 +1,45 @@ +# Generated migration for NauCourseFilter model + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('nau_openedx_extensions', '0011_alter_ssopartnerintegration_unique_together'), + ] + + operations = [ + migrations.CreateModel( + name='NauCourseFilter', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('course_id', models.CharField( + db_index=True, + help_text='Course key string (e.g. course-v1:ORG+ID+Run)', + max_length=255, + verbose_name='Course ID', + )), + ('filter_type', models.CharField( + help_text=( + 'Filter key name as stored in other_course_settings ' + '(e.g. filter_enrollment_by_domain_list, filter_enrollment_require_nif, ' + 'certificate_require_portuguese_citizen_card)' + ), + max_length=255, + verbose_name='Filter Type', + )), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'NAU Course Filter', + 'verbose_name_plural': 'NAU Course Filters', + 'ordering': ['course_id', 'filter_type'], + }, + ), + migrations.AlterUniqueTogether( + name='naucoursefilter', + unique_together={('course_id', 'filter_type')}, + ), + ] diff --git a/nau_openedx_extensions/models.py b/nau_openedx_extensions/models.py index 54f63409..1118f533 100644 --- a/nau_openedx_extensions/models.py +++ b/nau_openedx_extensions/models.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, unicode_literals +from nau_openedx_extensions.course_filters.models import NauCourseFilter # pylint: disable=unused-import from nau_openedx_extensions.custom_registration_form.models import NauUserExtendedModel # pylint: disable=unused-import from nau_openedx_extensions.enrollment_by_domain.models import ( # pylint: disable=unused-import EnrollmentAllowedDomain, diff --git a/nau_openedx_extensions/studio/apps.py b/nau_openedx_extensions/studio/apps.py index f4e83def..b1a8f058 100644 --- a/nau_openedx_extensions/studio/apps.py +++ b/nau_openedx_extensions/studio/apps.py @@ -19,3 +19,15 @@ class NauOpenCmsConfig(AppConfig): }, }, } + + def ready(self): + """ + Connect CMS signal handlers once the app registry is ready. + """ + from xmodule.modulestore.django import SignalHandler # pylint: disable=import-error,import-outside-toplevel + + from nau_openedx_extensions.course_filters.handlers import ( # pylint: disable=import-outside-toplevel + course_published_handler, + ) + + SignalHandler.course_published.connect(course_published_handler) diff --git a/nau_openedx_extensions/studio/management/commands/sync_course_filters.py b/nau_openedx_extensions/studio/management/commands/sync_course_filters.py new file mode 100644 index 00000000..763f9f16 --- /dev/null +++ b/nau_openedx_extensions/studio/management/commands/sync_course_filters.py @@ -0,0 +1,93 @@ +""" +Management command to backfill NauCourseFilter from MongoDB other_course_settings. + +This command iterates over all (or selected) courses, reads their +other_course_settings from MongoDB, and populates the NauCourseFilter table +in MySQL. Run this once after deploying the NauCourseFilter model to bring +historical data in sync before the course_published signal takes over. + +Sync all courses: + python manage.py cms sync_course_filters + +Sync a specific course: + python manage.py cms sync_course_filters --course-id course-v1:ORG+ID+Run + +Sync from a given index (useful to resume a partial run): + python manage.py cms sync_course_filters --index 50 +""" + +import traceback + +from django.core.management.base import BaseCommand +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=import-error + +from nau_openedx_extensions.course_filters.sync import sync_course_filters_for_course + + +class Command(BaseCommand): + """ + Backfill NauCourseFilter rows for all courses from other_course_settings. + """ + + help = "Backfill NauCourseFilter rows from MongoDB other_course_settings" + + def add_arguments(self, parser): + parser.add_argument( + "--course-id", + type=str, + default=None, + help="Sync a single course by its course key string (e.g. course-v1:ORG+ID+Run)", + ) + parser.add_argument( + "--index", + type=int, + default=0, + help="Start index of courses to process (useful to resume a partial run)", + ) + + def log_msg(self, msg): + self.stdout.write(str(msg)) + self.stdout.flush() + + def handle(self, *args, **options): + """Execute the command.""" + single_course_id = options.get("course_id") + + if single_course_id: + course_ids = [single_course_id] + else: + module_store = modulestore() + courses = module_store.get_courses() + course_ids = sorted(str(c.id) for c in courses) + + start_index = options.get("index", 0) + total = len(course_ids) + totals = {"created": 0, "deleted": 0, "unchanged": 0, "errors": 0} + + self.log_msg(f"Syncing course filters for {total} course(s) starting at index {start_index}.") + + for index in range(start_index, total): + course_id = course_ids[index] + self.log_msg(f"[{index + 1}/{total}] Processing {course_id}") + + try: + course_key = CourseKey.from_string(course_id) + result = sync_course_filters_for_course(course_key) + totals["created"] += result["created"] + totals["deleted"] += result["deleted"] + totals["unchanged"] += result["unchanged"] + + if result["created"] or result["deleted"]: + self.log_msg( + f" created={result['created']} deleted={result['deleted']} unchanged={result['unchanged']}" + ) + except Exception: # pylint: disable=broad-except + totals["errors"] += 1 + self.log_msg(f" ERROR processing {course_id}:") + self.log_msg(traceback.format_exc()) + + self.log_msg( + f"\nDone. created={totals['created']} deleted={totals['deleted']} " + f"unchanged={totals['unchanged']} errors={totals['errors']}" + )