Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nau_openedx_extensions/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Empty file.
29 changes: 29 additions & 0 deletions nau_openedx_extensions/course_filters/admin.py
Original file line number Diff line number Diff line change
@@ -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",),
}),
)
35 changes: 35 additions & 0 deletions nau_openedx_extensions/course_filters/handlers.py
Original file line number Diff line number Diff line change
@@ -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)
45 changes: 45 additions & 0 deletions nau_openedx_extensions/course_filters/models.py
Original file line number Diff line number Diff line change
@@ -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}"
79 changes: 79 additions & 0 deletions nau_openedx_extensions/course_filters/sync.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
60 changes: 60 additions & 0 deletions nau_openedx_extensions/course_filters/tests/test_handlers.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading