Skip to content

Commit 9257d77

Browse files
committedFeb 4, 2025··
🔧(backend) allow to configure JOANIE_LMS_BACKENDS through env var
Currently, LMS Backends list is hardcoded in joanie settings. This is weird as if we want to setup new LMS that means we have to update those settings then make a release. Furthermore, it is also weird from an Open Source point of view as those settings are not overridable easily. So in order to fix that, we now allow to set all LMS backend configuration through a JSON array string environment variable. If you are a Joanie developer, you may have to update our `env.d/common` file to remove old LMS env vars and add the new `JOANIE_LMS_BACKENDS`. Take a look at `env.d/common.dist` as an example.
1 parent 3acb98e commit 9257d77

File tree

7 files changed

+239
-50
lines changed

7 files changed

+239
-50
lines changed
 

‎CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### Changed
12+
13+
- JOANIE_LMS_BACKENDS is now configurable through env vars
14+
as a JSON string array of LMS Backends
15+
1116
## [2.14.1] - 2025-02-03
1217

1318
### Fixed

‎docs/explanation/lms-connection.md

+8-7
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,19 @@ with your LMS.
66
## Settings
77

88
You can link Joanie to several LMSs. You have to define your LMSs through the `JOANIE_LMS_BACKENDS`
9-
settings which is a list of LMS configuration.
9+
environment variable which is a JSON array string of LMS configuration.
1010

1111
e.g: A basic configuration
1212

13-
```python
14-
JOANIE_LMS_BACKENDS = [
13+
```shell
14+
JOANIE_LMS_BACKENDS = '[
1515
{
16-
"BACKEND": values.Value(environ_name="MY_LMS_BACKEND", environ_prefix=None),
17-
"BASE_URL": values.Value(environ_name="MY_LMS_BASE_URL", environ_prefix=None),
18-
"SELECTOR_REGEX" : values.Value(environ_name="MY_LMS_SELECTOR_REGEX", environ_prefix=None)
16+
"BACKEND": "MY_LMS_BACKEND",
17+
"BASE_URL": "MY_LMS_BASE_URL",
18+
"SELECTOR_REGEX" : "ˆMY_LMS_SELECTOR_REGEX$"
19+
"COURSE_REGEX" : "ˆMY_LMS_COURSE_REGEX_REGEX$"
1920
}
20-
]
21+
]'
2122
```
2223

2324
`BACKEND`, `BASE_URL`, `SELECTOR_REGEX` are the three settings required by `LMSHandler`. In fact,

‎docs/moodle.md

+13-3
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,23 @@ we need to set up a Moodle webservice, and a Moodle webservice client.
9393

9494
## Setup Moodle settings in Joanie
9595

96-
### Set Moodle backend environment variables
96+
### Declare a Moodle backend in JOANIE_LMS_BACKENDS environment variable
9797

9898
- MOODLE_API_TOKEN: the token of the Joanie user in Moodle
9999
- MOODLE_BACKEND: use to override the Moodle backend module in Joanie
100100
- MOODLE_BASE_URL: the URL of the Moodle webservice (e.g. `"http://moodle.test/webservice/rest/server.php"`)
101101
- MOODLE_SELECTOR_REGEX: a regex to match the Moodle backend (e.g. `r"^.*/course/view.php\?id=.*$"`)
102102
- MOODLE_COURSE_REGEX: a regex to match the Moodle course id (e.g. `r"^.*/course/view.php\?id=(.*)$"`)
103103

104-
105-
104+
```shell
105+
JOANIE_LMS_BACKENDS = '[
106+
# ...
107+
{
108+
"API_TOKEN": "FakeApiKeyForExample",
109+
"BACKEND": "joanie.lms_handler.backends.moodle.MoodleLMSBackend",
110+
"BASE_URL": "http://moodle.test/webservice/rest/server.php",
111+
"SELECTOR_REGEX": "^.*/course/view.php\\?id=.*$",
112+
"COURSE_REGEX": "^.*/courses/(?P<course_id>.*)/course/?$"
113+
}
114+
]'
115+
```

‎env.d/development/common.dist

+8-6
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ DJANGO_SUPERUSER_PASSWORD=admin
88
PYTHONPATH=/app:/app/joanie
99

1010
# LMS BACKENDS
11-
# OpenEdX
12-
EDX_BASE_URL=http://edx:8073
13-
EDX_SELECTOR_REGEX='^.*/courses/(?P<course_id>.*)/course/?$'
14-
EDX_COURSE_REGEX='^.*/courses/(?P<course_id>.*)/course/?$'
15-
EDX_API_TOKEN=FakeEdXAPIKey
16-
EDX_BACKEND=joanie.lms_handler.backends.dummy.DummyLMSBackend
11+
JOANIE_LMS_BACKENDS = '[{
12+
"API_TOKEN": "FakeEdXAPIKey",
13+
"BACKEND": "joanie.lms_handler.backends.dummy.DummyLMSBackend",
14+
"BASE_URL": "http://edx:8073",
15+
"SELECTOR_REGEX": "^.*/courses/(?P<course_id>.*)/course/?$",
16+
"COURSE_REGEX": "^.*/courses/(?P<course_id>.*)/course/?$",
17+
"COURSE_RUN_SYNC_NO_UPDATE_FIELDS": ["languages"]
18+
}]'
1719

1820
#JWT
1921
DJANGO_JWT_PRIVATE_SIGNING_KEY=ThisIsAnExampleKeyForDevPurposeOnly

‎src/backend/joanie/core/utils/__init__.py

+92-1
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import collections.abc
77
import hashlib
88
import json
9-
import math
9+
import re
1010

1111
from django.utils.text import slugify
1212

1313
from configurations import values
14+
from configurations.values import ValidationMixin
1415
from PIL import ImageFile as PillowImageFile
1516

1617

@@ -118,6 +119,96 @@ def to_python(self, value):
118119
return json.loads(value)
119120

120121

122+
class LMSBackendValidator:
123+
"""
124+
Validator for the LMS Backends configuration. Take a look at settings
125+
`JOANIE_LMS_BACKENDS` for more information.
126+
"""
127+
128+
BACKEND_REQUIRED_KEYS = ["BACKEND", "BASE_URL", "COURSE_REGEX", "SELECTOR_REGEX"]
129+
130+
def __init__(self, value):
131+
self.__call__(value)
132+
133+
def _validate_required_keys(self, backend):
134+
"""
135+
Validate that the backend dictionary contains all the required keys.
136+
"""
137+
for key in self.BACKEND_REQUIRED_KEYS:
138+
if key not in backend:
139+
raise ValueError(f"Missing key {key} in LMS Backend.")
140+
141+
def _validate_regex_properties(self, backend):
142+
"""
143+
Validate that the values of the COURSE_REGEX and SELECTOR_REGEX properties
144+
are valid regex strings.
145+
"""
146+
course_regex = backend["COURSE_REGEX"]
147+
selector_regex = backend["SELECTOR_REGEX"]
148+
149+
for regex in [course_regex, selector_regex]:
150+
try:
151+
re.compile(regex)
152+
except re.error as error:
153+
raise ValueError(f"Invalid regex {regex} in LMS Backend.") from error
154+
155+
def _validate_backend_path_string(self, path):
156+
"""
157+
Validate that the value of the BACKEND property
158+
is a valid python module path string.
159+
"""
160+
path_regex = r"^([a-zA-Z_]+\.)*[a-zA-Z_]+$"
161+
if not isinstance(path, str):
162+
raise ValueError(f"{path} must be a string.")
163+
164+
if not re.match(path_regex, path):
165+
raise ValueError(f"{path} must be a valid python module path string.")
166+
167+
def _validate_no_update_fields_value(self, value):
168+
"""
169+
Validate that the value of
170+
the COURSE_RUN_SYNC_NO_UPDATE_FIELDS property is a list.
171+
"""
172+
if value is not None and not isinstance(value, list):
173+
raise ValueError("COURSE_RUN_SYNC_NO_UPDATE_FIELDS must be a list.")
174+
175+
def _validate_backend(self, backend):
176+
self._validate_required_keys(backend)
177+
self._validate_regex_properties(backend)
178+
self._validate_backend_path_string(backend["BACKEND"])
179+
self._validate_no_update_fields_value(
180+
backend.get("COURSE_RUN_SYNC_NO_UPDATE_FIELDS")
181+
)
182+
183+
def __call__(self, value):
184+
"""
185+
Validate that the value is a list of dictionaries which describe an LMS Backends.
186+
And that each backend has the correct properties.
187+
"""
188+
if not isinstance(value, list):
189+
raise ValueError("LMS Backends must be a list of dictionaries.")
190+
191+
for backend in value:
192+
self._validate_backend(backend)
193+
194+
195+
class LMSBackendsValue(ValidationMixin, values.Value):
196+
"""
197+
A custom value class based on the JSONValue class that allows to load
198+
a JSON string and use it as a value. It also validates that the JSON
199+
object is a list of dictionaries which describe an LMS Backends.
200+
"""
201+
202+
validator = LMSBackendValidator
203+
204+
def to_python(self, value):
205+
"""
206+
Return the python representation of the JSON string.
207+
"""
208+
backends = json.loads(value)
209+
return super().to_python(backends)
210+
211+
121212
class Echo:
122213
"""An object that implements just the write method of the file-like
123214
interface.

‎src/backend/joanie/settings.py

+2-33
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from configurations import Configuration, values
2020
from sentry_sdk.integrations.django import DjangoIntegration
2121

22-
from joanie.core.utils import JSONValue
22+
from joanie.core.utils import JSONValue, LMSBackendsValue
2323
from joanie.core.utils.sentry import before_send
2424

2525
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -230,38 +230,7 @@ class Base(Configuration):
230230
]
231231

232232
# Joanie
233-
JOANIE_LMS_BACKENDS = [
234-
{
235-
"API_TOKEN": values.Value(
236-
environ_name="EDX_API_TOKEN", environ_prefix=None
237-
),
238-
"BACKEND": values.Value(environ_name="EDX_BACKEND", environ_prefix=None),
239-
"BASE_URL": values.Value(environ_name="EDX_BASE_URL", environ_prefix=None),
240-
"SELECTOR_REGEX": values.Value(
241-
r".*", environ_name="EDX_SELECTOR_REGEX", environ_prefix=None
242-
),
243-
"COURSE_REGEX": values.Value(
244-
r".*", environ_name="EDX_COURSE_REGEX", environ_prefix=None
245-
),
246-
"COURSE_RUN_SYNC_NO_UPDATE_FIELDS": ["languages"],
247-
},
248-
{
249-
"API_TOKEN": values.Value(
250-
environ_name="MOODLE_API_TOKEN", environ_prefix=None
251-
),
252-
"BACKEND": values.Value(environ_name="MOODLE_BACKEND", environ_prefix=None),
253-
"BASE_URL": values.Value(
254-
environ_name="MOODLE_BASE_URL", environ_prefix=None
255-
),
256-
"SELECTOR_REGEX": values.Value(
257-
r"^disabled$", environ_name="MOODLE_SELECTOR_REGEX", environ_prefix=None
258-
),
259-
"COURSE_REGEX": values.Value(
260-
r"^disabled$", environ_name="MOODLE_COURSE_REGEX", environ_prefix=None
261-
),
262-
"COURSE_RUN_SYNC_NO_UPDATE_FIELDS": [],
263-
},
264-
]
233+
JOANIE_LMS_BACKENDS = LMSBackendsValue([], environ_prefix=None)
265234
JOANIE_COURSE_RUN_SYNC_SECRETS = values.ListValue([], environ_prefix=None)
266235
MOODLE_AUTH_METHOD = values.Value(
267236
"oauth2", environ_name="MOODLE_AUTH_METHOD", environ_prefix=None
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Test suite for the LMSBackendsValue custom field."""
2+
3+
import json
4+
5+
from django.test import TestCase
6+
7+
from joanie.core.utils import LMSBackendsValue, LMSBackendValidator
8+
9+
10+
class TestCaseLMSBackendsValue(TestCase):
11+
"""Test suite for the LMSBackendsValue custom field."""
12+
13+
def setUp(self):
14+
self.lms_configuration = {
15+
"API_TOKEN": "FakeEdXAPIKey",
16+
"BACKEND": "joanie.lms_handler.backends.dummy.DummyLMSBackend",
17+
"BASE_URL": "http://edx:8073",
18+
"SELECTOR_REGEX": "^.*/courses/(?P<course_id>.*)/course/?$",
19+
"COURSE_REGEX": "^.*/courses/(?P<course_id>.*)/course/?$",
20+
"COURSE_RUN_SYNC_NO_UPDATE_FIELDS": ["languages"],
21+
}
22+
23+
def test_lms_backends_value_return_list_of_configuration(self):
24+
"""If all validation pass, the return value should be a list of configuration."""
25+
26+
envvar = json.dumps([self.lms_configuration])
27+
value = LMSBackendsValue().to_python(envvar)
28+
29+
self.assertIsInstance(value, list)
30+
self.assertEqual(len(value), 1)
31+
backend = value[0]
32+
self.assertIsInstance(backend, dict)
33+
self.assertEqual(backend["API_TOKEN"], "FakeEdXAPIKey")
34+
self.assertEqual(
35+
backend["BACKEND"], "joanie.lms_handler.backends.dummy.DummyLMSBackend"
36+
)
37+
self.assertEqual(backend["BASE_URL"], "http://edx:8073")
38+
self.assertEqual(
39+
backend["SELECTOR_REGEX"], "^.*/courses/(?P<course_id>.*)/course/?$"
40+
)
41+
self.assertEqual(
42+
backend["COURSE_REGEX"], "^.*/courses/(?P<course_id>.*)/course/?$"
43+
)
44+
self.assertEqual(backend["COURSE_RUN_SYNC_NO_UPDATE_FIELDS"], ["languages"])
45+
46+
def test_lms_backends_value_validate_required_keys(self):
47+
"""It should ensure that all required keys are defined."""
48+
49+
for key in LMSBackendValidator.BACKEND_REQUIRED_KEYS:
50+
with self.subTest("Missing key", key=key):
51+
values = self.lms_configuration.copy()
52+
values.pop(key)
53+
envvar = json.dumps([values])
54+
55+
with self.assertRaises(ValueError):
56+
LMSBackendsValue().to_python(envvar)
57+
58+
def test_lms_backends_value_validate_regex_properties(self):
59+
"""It should ensure that the regex properties are valid."""
60+
61+
invalid_regex = r"ˆ][$"
62+
63+
# Both selector and course regex should be validated
64+
for key in ["SELECTOR_REGEX", "COURSE_REGEX"]:
65+
with self.subTest("Invalid regex", key=key):
66+
values = self.lms_configuration.copy()
67+
values[key] = invalid_regex
68+
envvar = json.dumps([values])
69+
70+
with self.assertRaises(ValueError) as exception:
71+
LMSBackendsValue().to_python(envvar)
72+
73+
self.assertEqual(
74+
str(exception.exception), "Invalid regex ˆ][$ in LMS Backend."
75+
)
76+
77+
def test_lms_backends_value_validate_backend_path(self):
78+
"""It should ensure that the backend path string is valid."""
79+
values = self.lms_configuration.copy()
80+
values["BACKEND"] = "invalid.path..to.backend"
81+
envvar = json.dumps([values])
82+
83+
with self.assertRaises(ValueError) as exception:
84+
LMSBackendsValue().to_python(envvar)
85+
86+
self.assertEqual(
87+
str(exception.exception),
88+
"invalid.path..to.backend must be a valid python module path string.",
89+
)
90+
91+
def test_lms_backends_value_validate_no_update_fields_value(self):
92+
"""
93+
It should ensure that the COURSE_RUN_SYNC_NO_UPDATE_FIELDS value is a list
94+
if defined
95+
"""
96+
values = self.lms_configuration.copy()
97+
values.pop("COURSE_RUN_SYNC_NO_UPDATE_FIELDS")
98+
envvar = json.dumps([values])
99+
100+
# No error should be raised if the value is not defined
101+
LMSBackendsValue().to_python(envvar)
102+
103+
values.update({"COURSE_RUN_SYNC_NO_UPDATE_FIELDS": "invalid"})
104+
envvar = json.dumps([values])
105+
106+
with self.assertRaises(ValueError) as exception:
107+
LMSBackendsValue().to_python(envvar)
108+
109+
self.assertEqual(
110+
str(exception.exception), "COURSE_RUN_SYNC_NO_UPDATE_FIELDS must be a list."
111+
)

0 commit comments

Comments
 (0)
Please sign in to comment.