lms.backend.moodle.backend

  1# pylint: disable=abstract-method
  2
  3import logging
  4import typing
  5import urllib.parse
  6
  7import bs4
  8import edq.net.request
  9import requests
 10
 11import lms.model.backend
 12import lms.model.constants
 13import lms.util.net
 14
 15_logger = logging.getLogger(__name__)
 16
 17ROLE_MAPPING: typing.Dict[str, lms.model.users.CourseRole] = {
 18    "guest": lms.model.users.CourseRole.OTHER,
 19    "student": lms.model.users.CourseRole.STUDENT,
 20    "non-editing teacher": lms.model.users.CourseRole.GRADER,
 21    "teacher": lms.model.users.CourseRole.ADMIN,
 22    "manager": lms.model.users.CourseRole.OWNER,
 23}
 24
 25# Moodle shows 5000 users per page when asked to fetch all results.
 26RESULTS_PER_PAGE: int = 5000
 27
 28class MoodleBackend(lms.model.backend.APIBackend):
 29    """ An API backend for the Moodle LMS. """
 30
 31    def __init__(self,
 32            **kwargs: typing.Any) -> None:
 33        super().__init__(**kwargs)
 34
 35        assert(self.config.backend_type == lms.model.constants.BackendType.MOODLE)
 36
 37        if (self.config.auth_user is None):
 38            raise ValueError("Moodle backends require a username.")
 39
 40        self.auth_user: str = self.config.auth_user
 41        """
 42        The user to authenticate with.
 43        This is set in config and compied for type checking.
 44        """
 45
 46        if (self.config.auth_password is None):
 47            raise ValueError("Moodle backends require a password.")
 48
 49        self.auth_password: str = self.config.auth_password.cleartext
 50        """
 51        The (cleartext) password to authenticate with.
 52        This is set in config and compied for type checking.
 53        """
 54
 55        self._session_headers: typing.Union[typing.Dict[str, typing.Any], None] = None
 56        """ The headers (e.g., cookies) for our logged in Moodle session. """
 57
 58    def reset_connection(self) -> None:
 59        self._session_headers = None
 60
 61    def get_standard_headers(self, write: bool = False) -> typing.Dict[str, str]:
 62        headers = super().get_standard_headers(write)
 63
 64        if (self._session_headers is not None):
 65            headers.update(self._session_headers)
 66
 67        return headers
 68
 69    def _parse_cookies(self, response: requests.Response) -> typing.Dict[str, typing.Any]:
 70        """
 71        Parse Moodle cookies.
 72        Return fake cookies when testing.
 73        """
 74
 75        if (self.is_testing()):
 76            return {
 77                'moodlesession': 'testing-moodle-session',
 78                'moodleid1_': 'testing-moodle-id',
 79            }
 80
 81        return lms.util.net.parse_cookies(response.headers.get('set-cookie', None))
 82
 83    def _login(self, update_server: bool = True) -> None:
 84        """
 85        Try to login to the Moodle server.
 86        If `update_server` is true, then this may try to update the backend's server location if redirected by the Moodle server.
 87        """
 88
 89        # Check if we are already logged in.
 90        if (self._session_headers is not None):
 91            return
 92
 93        response, body = edq.net.request.make_get(self.server + '/login/index.php')
 94        cookies = self._parse_cookies(response)
 95
 96        new_cookies = {
 97            'MoodleSession': cookies['moodlesession'],
 98        }
 99        text_cookies = '; '.join(['='.join(items) for items in new_cookies.items()])
100
101        # Parse the login token from the page HTML.
102        document = bs4.BeautifulSoup(body, 'html.parser')
103        token = document.select('input[name="logintoken"]')[0]['value']
104
105        headers = {
106            'cookie': text_cookies,
107            'host': urllib.parse.urlparse(self.server).netloc,
108        }
109
110        data = {
111            'logintoken': token,
112            'username': self.auth_user,
113            'password': self.auth_password,
114        }
115
116        response, _ = edq.net.request.make_post(self.server + '/login/index.php',
117                headers = headers, data = data,
118                allow_redirects = False)
119
120        # Check for a successful login.
121        cookies = self._parse_cookies(response)
122        if ('moodleid1_' in cookies):
123            self._session_headers = {
124                'cookie': response.headers.get('set-cookie', None),
125                # Insert a header to identify the user.
126                'edq-lms-moodle-user': self.auth_user,
127            }
128            return
129
130        # Login Failed
131
132        # The specified server/host needs to match exactly what the Moodle server wants it to be,
133        # e.g., `127.0.0.1` does not work when the server wants the host to be `localhost`.
134        # If these do not match, we will get a redirect here.
135        # Use this redirect to discover the correct server.
136        location = response.headers.get('location', None)
137        if (update_server and (location is not None) and (not location.startswith(self.server))):
138            parts = urllib.parse.urlparse(location)
139            host = f"{parts.scheme}://{parts.netloc}"
140
141            _logger.debug(("Mismatch in the client-specified server ('%s') and server-requested host ('%s')."
142                    + " To avoid extra requests, update the server (e.g., `--server`) to match the host."),
143                    self.server, host)
144
145            # Update the server and try to login again (without updating the server again (to avoid loops)).
146            self.server = host
147            self._login(update_server = False)
148            return
149
150        raise ValueError(f"Could not log into Moodle server ({self.server}) with user '{self.auth_user}'. Is username/password correct?")
151
152    def courses_list(self,
153            **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]:
154        self._login()
155
156        url = self.server + "/user/profile.php"
157        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
158
159        document = bs4.BeautifulSoup(response.text, 'html.parser')
160        cards = document.select('div.card-body')
161
162        node = None
163        for card in cards:
164            text = card.get_text()
165            if (text.startswith("Course details")):
166                node = card
167                break
168
169        if (node is None):
170            return []
171
172        links = node.select('a')
173
174        courses = []
175        for link in links:
176            name = link.get_text()
177
178            href = link.get('href', None)
179            if (href is None):
180                continue
181
182            id = str(href).rsplit("=", maxsplit = 1)[-1]
183
184            courses.append(lms.model.courses.Course(
185                id = id,
186                name = name,
187            ))
188
189        return sorted(courses)
190
191    def courses_users_list(self,
192            course_id: str,
193            **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]:
194        self._login()
195
196        url = f"{self.server}/user/index.php?id={course_id}&perpage={RESULTS_PER_PAGE}"
197        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
198
199        document = bs4.BeautifulSoup(response.text, 'html.parser')
200
201        headers = document.select('table#participants thead tr th')
202        # { course_user_attribute (e.g. name): column class, ... }
203        classes = {}
204        for header in headers:
205            column_classes = header.get('class', None)
206            if (column_classes is None):
207                continue
208
209            # Parse and store the column's class (e.g. "c0").
210            # This class is referenced when storing corresponding course user data.
211            if (isinstance(column_classes, str)):
212                column_class = column_classes
213            else:
214                if ('header' in column_classes):
215                    column_classes.remove('header')
216
217                if (len(column_classes) != 1):
218                    continue
219
220                column_class = column_classes[0]
221
222            elements = header.select('div.commands a')
223            for element in elements:
224                attribute = element.get('data-column', None)
225                if (attribute is None):
226                    continue
227
228                classes[attribute] = column_class
229
230        rows = document.select('table#participants tbody tr:not(.emptyrow)')
231
232        users = []
233        for row in rows:
234            try:
235                id = row.select_one('.cell input[type="checkbox"]').get('id', None).removeprefix('user')  # type: ignore[union-attr]
236                name = row.select_one(f'.cell.{classes["fullname"]} a span').get('title', None).removeprefix('__EMPTY_NAME__ ')  # type: ignore[union-attr] # pylint: disable=line-too-long
237                email = row.select_one(f'.cell.{classes["email"]}').get_text()  # type: ignore[union-attr]
238                raw_role = row.select_one(f'.cell.{classes["roles"]} span a').get_text().strip().lower()  # type: ignore[union-attr]
239            except AttributeError as _:
240                _logger.warning("Unable to list users. Moodle data structure has changed. Contact project developers.")
241                continue
242
243            # HACK(JK): Moodle does not allow the Guest role when loading test data, so we patch the guest role during testing.
244            if (email == 'course-other@test.edulinq.org'):
245                raw_role = "guest"
246
247            users.append(lms.model.users.CourseUser(
248                id = id,
249                name = name,
250                email = email,
251                raw_role = raw_role,
252                role = ROLE_MAPPING.get(raw_role, None),
253            ))
254
255        return users
ROLE_MAPPING: Dict[str, lms.model.users.CourseRole] = {'guest': <CourseRole.OTHER: 'other'>, 'student': <CourseRole.STUDENT: 'student'>, 'non-editing teacher': <CourseRole.GRADER: 'grader'>, 'teacher': <CourseRole.ADMIN: 'admin'>, 'manager': <CourseRole.OWNER: 'owner'>}
RESULTS_PER_PAGE: int = 5000
class MoodleBackend(lms.model.backend.APIBackend):
 29class MoodleBackend(lms.model.backend.APIBackend):
 30    """ An API backend for the Moodle LMS. """
 31
 32    def __init__(self,
 33            **kwargs: typing.Any) -> None:
 34        super().__init__(**kwargs)
 35
 36        assert(self.config.backend_type == lms.model.constants.BackendType.MOODLE)
 37
 38        if (self.config.auth_user is None):
 39            raise ValueError("Moodle backends require a username.")
 40
 41        self.auth_user: str = self.config.auth_user
 42        """
 43        The user to authenticate with.
 44        This is set in config and compied for type checking.
 45        """
 46
 47        if (self.config.auth_password is None):
 48            raise ValueError("Moodle backends require a password.")
 49
 50        self.auth_password: str = self.config.auth_password.cleartext
 51        """
 52        The (cleartext) password to authenticate with.
 53        This is set in config and compied for type checking.
 54        """
 55
 56        self._session_headers: typing.Union[typing.Dict[str, typing.Any], None] = None
 57        """ The headers (e.g., cookies) for our logged in Moodle session. """
 58
 59    def reset_connection(self) -> None:
 60        self._session_headers = None
 61
 62    def get_standard_headers(self, write: bool = False) -> typing.Dict[str, str]:
 63        headers = super().get_standard_headers(write)
 64
 65        if (self._session_headers is not None):
 66            headers.update(self._session_headers)
 67
 68        return headers
 69
 70    def _parse_cookies(self, response: requests.Response) -> typing.Dict[str, typing.Any]:
 71        """
 72        Parse Moodle cookies.
 73        Return fake cookies when testing.
 74        """
 75
 76        if (self.is_testing()):
 77            return {
 78                'moodlesession': 'testing-moodle-session',
 79                'moodleid1_': 'testing-moodle-id',
 80            }
 81
 82        return lms.util.net.parse_cookies(response.headers.get('set-cookie', None))
 83
 84    def _login(self, update_server: bool = True) -> None:
 85        """
 86        Try to login to the Moodle server.
 87        If `update_server` is true, then this may try to update the backend's server location if redirected by the Moodle server.
 88        """
 89
 90        # Check if we are already logged in.
 91        if (self._session_headers is not None):
 92            return
 93
 94        response, body = edq.net.request.make_get(self.server + '/login/index.php')
 95        cookies = self._parse_cookies(response)
 96
 97        new_cookies = {
 98            'MoodleSession': cookies['moodlesession'],
 99        }
100        text_cookies = '; '.join(['='.join(items) for items in new_cookies.items()])
101
102        # Parse the login token from the page HTML.
103        document = bs4.BeautifulSoup(body, 'html.parser')
104        token = document.select('input[name="logintoken"]')[0]['value']
105
106        headers = {
107            'cookie': text_cookies,
108            'host': urllib.parse.urlparse(self.server).netloc,
109        }
110
111        data = {
112            'logintoken': token,
113            'username': self.auth_user,
114            'password': self.auth_password,
115        }
116
117        response, _ = edq.net.request.make_post(self.server + '/login/index.php',
118                headers = headers, data = data,
119                allow_redirects = False)
120
121        # Check for a successful login.
122        cookies = self._parse_cookies(response)
123        if ('moodleid1_' in cookies):
124            self._session_headers = {
125                'cookie': response.headers.get('set-cookie', None),
126                # Insert a header to identify the user.
127                'edq-lms-moodle-user': self.auth_user,
128            }
129            return
130
131        # Login Failed
132
133        # The specified server/host needs to match exactly what the Moodle server wants it to be,
134        # e.g., `127.0.0.1` does not work when the server wants the host to be `localhost`.
135        # If these do not match, we will get a redirect here.
136        # Use this redirect to discover the correct server.
137        location = response.headers.get('location', None)
138        if (update_server and (location is not None) and (not location.startswith(self.server))):
139            parts = urllib.parse.urlparse(location)
140            host = f"{parts.scheme}://{parts.netloc}"
141
142            _logger.debug(("Mismatch in the client-specified server ('%s') and server-requested host ('%s')."
143                    + " To avoid extra requests, update the server (e.g., `--server`) to match the host."),
144                    self.server, host)
145
146            # Update the server and try to login again (without updating the server again (to avoid loops)).
147            self.server = host
148            self._login(update_server = False)
149            return
150
151        raise ValueError(f"Could not log into Moodle server ({self.server}) with user '{self.auth_user}'. Is username/password correct?")
152
153    def courses_list(self,
154            **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]:
155        self._login()
156
157        url = self.server + "/user/profile.php"
158        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
159
160        document = bs4.BeautifulSoup(response.text, 'html.parser')
161        cards = document.select('div.card-body')
162
163        node = None
164        for card in cards:
165            text = card.get_text()
166            if (text.startswith("Course details")):
167                node = card
168                break
169
170        if (node is None):
171            return []
172
173        links = node.select('a')
174
175        courses = []
176        for link in links:
177            name = link.get_text()
178
179            href = link.get('href', None)
180            if (href is None):
181                continue
182
183            id = str(href).rsplit("=", maxsplit = 1)[-1]
184
185            courses.append(lms.model.courses.Course(
186                id = id,
187                name = name,
188            ))
189
190        return sorted(courses)
191
192    def courses_users_list(self,
193            course_id: str,
194            **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]:
195        self._login()
196
197        url = f"{self.server}/user/index.php?id={course_id}&perpage={RESULTS_PER_PAGE}"
198        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
199
200        document = bs4.BeautifulSoup(response.text, 'html.parser')
201
202        headers = document.select('table#participants thead tr th')
203        # { course_user_attribute (e.g. name): column class, ... }
204        classes = {}
205        for header in headers:
206            column_classes = header.get('class', None)
207            if (column_classes is None):
208                continue
209
210            # Parse and store the column's class (e.g. "c0").
211            # This class is referenced when storing corresponding course user data.
212            if (isinstance(column_classes, str)):
213                column_class = column_classes
214            else:
215                if ('header' in column_classes):
216                    column_classes.remove('header')
217
218                if (len(column_classes) != 1):
219                    continue
220
221                column_class = column_classes[0]
222
223            elements = header.select('div.commands a')
224            for element in elements:
225                attribute = element.get('data-column', None)
226                if (attribute is None):
227                    continue
228
229                classes[attribute] = column_class
230
231        rows = document.select('table#participants tbody tr:not(.emptyrow)')
232
233        users = []
234        for row in rows:
235            try:
236                id = row.select_one('.cell input[type="checkbox"]').get('id', None).removeprefix('user')  # type: ignore[union-attr]
237                name = row.select_one(f'.cell.{classes["fullname"]} a span').get('title', None).removeprefix('__EMPTY_NAME__ ')  # type: ignore[union-attr] # pylint: disable=line-too-long
238                email = row.select_one(f'.cell.{classes["email"]}').get_text()  # type: ignore[union-attr]
239                raw_role = row.select_one(f'.cell.{classes["roles"]} span a').get_text().strip().lower()  # type: ignore[union-attr]
240            except AttributeError as _:
241                _logger.warning("Unable to list users. Moodle data structure has changed. Contact project developers.")
242                continue
243
244            # HACK(JK): Moodle does not allow the Guest role when loading test data, so we patch the guest role during testing.
245            if (email == 'course-other@test.edulinq.org'):
246                raw_role = "guest"
247
248            users.append(lms.model.users.CourseUser(
249                id = id,
250                name = name,
251                email = email,
252                raw_role = raw_role,
253                role = ROLE_MAPPING.get(raw_role, None),
254            ))
255
256        return users

An API backend for the Moodle LMS.

MoodleBackend(**kwargs: Any)
32    def __init__(self,
33            **kwargs: typing.Any) -> None:
34        super().__init__(**kwargs)
35
36        assert(self.config.backend_type == lms.model.constants.BackendType.MOODLE)
37
38        if (self.config.auth_user is None):
39            raise ValueError("Moodle backends require a username.")
40
41        self.auth_user: str = self.config.auth_user
42        """
43        The user to authenticate with.
44        This is set in config and compied for type checking.
45        """
46
47        if (self.config.auth_password is None):
48            raise ValueError("Moodle backends require a password.")
49
50        self.auth_password: str = self.config.auth_password.cleartext
51        """
52        The (cleartext) password to authenticate with.
53        This is set in config and compied for type checking.
54        """
55
56        self._session_headers: typing.Union[typing.Dict[str, typing.Any], None] = None
57        """ The headers (e.g., cookies) for our logged in Moodle session. """
auth_user: str

The user to authenticate with. This is set in config and compied for type checking.

auth_password: str

The (cleartext) password to authenticate with. This is set in config and compied for type checking.

def reset_connection(self) -> None:
59    def reset_connection(self) -> None:
60        self._session_headers = None

Inform the backend that their connection has been reset. Note that this is not on the individual HTTP connection level, but instead on the server level. For example, this is called when a testing server is reset (e.g., in a server runner).

def get_standard_headers(self, write: bool = False) -> Dict[str, str]:
62    def get_standard_headers(self, write: bool = False) -> typing.Dict[str, str]:
63        headers = super().get_standard_headers(write)
64
65        if (self._session_headers is not None):
66            headers.update(self._session_headers)
67
68        return headers

Get standard headers for this backend. Children should take care to set the write header when performing a write operation.

def courses_list(self, **kwargs: Any) -> List[lms.model.courses.Course]:
153    def courses_list(self,
154            **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]:
155        self._login()
156
157        url = self.server + "/user/profile.php"
158        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
159
160        document = bs4.BeautifulSoup(response.text, 'html.parser')
161        cards = document.select('div.card-body')
162
163        node = None
164        for card in cards:
165            text = card.get_text()
166            if (text.startswith("Course details")):
167                node = card
168                break
169
170        if (node is None):
171            return []
172
173        links = node.select('a')
174
175        courses = []
176        for link in links:
177            name = link.get_text()
178
179            href = link.get('href', None)
180            if (href is None):
181                continue
182
183            id = str(href).rsplit("=", maxsplit = 1)[-1]
184
185            courses.append(lms.model.courses.Course(
186                id = id,
187                name = name,
188            ))
189
190        return sorted(courses)

List the courses associated with the context user.

def courses_users_list(self, course_id: str, **kwargs: Any) -> List[lms.model.users.CourseUser]:
192    def courses_users_list(self,
193            course_id: str,
194            **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]:
195        self._login()
196
197        url = f"{self.server}/user/index.php?id={course_id}&perpage={RESULTS_PER_PAGE}"
198        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
199
200        document = bs4.BeautifulSoup(response.text, 'html.parser')
201
202        headers = document.select('table#participants thead tr th')
203        # { course_user_attribute (e.g. name): column class, ... }
204        classes = {}
205        for header in headers:
206            column_classes = header.get('class', None)
207            if (column_classes is None):
208                continue
209
210            # Parse and store the column's class (e.g. "c0").
211            # This class is referenced when storing corresponding course user data.
212            if (isinstance(column_classes, str)):
213                column_class = column_classes
214            else:
215                if ('header' in column_classes):
216                    column_classes.remove('header')
217
218                if (len(column_classes) != 1):
219                    continue
220
221                column_class = column_classes[0]
222
223            elements = header.select('div.commands a')
224            for element in elements:
225                attribute = element.get('data-column', None)
226                if (attribute is None):
227                    continue
228
229                classes[attribute] = column_class
230
231        rows = document.select('table#participants tbody tr:not(.emptyrow)')
232
233        users = []
234        for row in rows:
235            try:
236                id = row.select_one('.cell input[type="checkbox"]').get('id', None).removeprefix('user')  # type: ignore[union-attr]
237                name = row.select_one(f'.cell.{classes["fullname"]} a span').get('title', None).removeprefix('__EMPTY_NAME__ ')  # type: ignore[union-attr] # pylint: disable=line-too-long
238                email = row.select_one(f'.cell.{classes["email"]}').get_text()  # type: ignore[union-attr]
239                raw_role = row.select_one(f'.cell.{classes["roles"]} span a').get_text().strip().lower()  # type: ignore[union-attr]
240            except AttributeError as _:
241                _logger.warning("Unable to list users. Moodle data structure has changed. Contact project developers.")
242                continue
243
244            # HACK(JK): Moodle does not allow the Guest role when loading test data, so we patch the guest role during testing.
245            if (email == 'course-other@test.edulinq.org'):
246                raw_role = "guest"
247
248            users.append(lms.model.users.CourseUser(
249                id = id,
250                name = name,
251                email = email,
252                raw_role = raw_role,
253                role = ROLE_MAPPING.get(raw_role, None),
254            ))
255
256        return users

List the users associated with the given course.

Inherited Members
lms.model.backend.APIBackend
config
backend_type
server
testing
is_testing
not_found
courses_get
courses_fetch
courses_assignments_get
courses_assignments_fetch
courses_assignments_list
courses_assignments_resolve_and_list
courses_assignments_scores_get
courses_assignments_scores_fetch
courses_assignments_scores_list
courses_assignments_scores_resolve_and_list
courses_assignments_scores_resolve_and_upload
courses_assignments_scores_upload
courses_gradebook_get
courses_gradebook_fetch
courses_gradebook_list
courses_gradebook_resolve_and_list
courses_gradebook_resolve_and_upload
courses_gradebook_upload
courses_groupsets_create
courses_groupsets_resolve_and_create
courses_groupsets_delete
courses_groupsets_resolve_and_delete
courses_groupsets_get
courses_groupsets_fetch
courses_groupsets_list
courses_groupsets_resolve_and_list
courses_groupsets_memberships_resolve_and_add
courses_groupsets_memberships_resolve_and_set
courses_groupsets_memberships_resolve_and_subtract
courses_groupsets_memberships_list
courses_groupsets_memberships_resolve_and_list
courses_groups_create
courses_groups_resolve_and_create
courses_groups_delete
courses_groups_resolve_and_delete
courses_groups_get
courses_groups_fetch
courses_groups_list
courses_groups_resolve_and_list
courses_groups_memberships_add
courses_groups_memberships_resolve_and_add
courses_groups_memberships_list
courses_groups_memberships_resolve_and_list
courses_groups_memberships_resolve_and_set
courses_groups_memberships_subtract
courses_groups_memberships_resolve_and_subtract
courses_quizzes_download
courses_quizzes_resolve_and_download
courses_quizzes_get
courses_quizzes_fetch
courses_quizzes_list
courses_quizzes_resolve_and_list
courses_quizzes_resolve_and_remove
courses_quizzes_remove
courses_quizzes_resolve_and_upload
courses_quizzes_upload
courses_syllabus_fetch
courses_syllabus_get
courses_users_get
courses_users_fetch
courses_users_resolve_and_list
courses_users_scores_get
courses_users_scores_fetch
courses_users_scores_list
courses_users_scores_resolve_and_list
parse_assignment_query
parse_assignment_queries
parse_course_query
parse_course_queries
parse_groupset_query
parse_groupset_queries
parse_group_query
parse_group_queries
parse_user_query
parse_user_queries
resolve_assignment_query
resolve_assignment_queries
resolve_course_query
resolve_course_queries
resolve_group_queries
resolve_group_query
resolve_groupset_queries
resolve_groupset_query
resolve_quiz_query
resolve_quiz_queries
resolve_user_queries