lms.backend.moodle.backend

  1# pylint: disable=abstract-method
  2
  3import json
  4import logging
  5import re
  6import typing
  7import urllib.parse
  8
  9import bs4
 10import edq.net.request
 11import requests
 12
 13import lms.backend.moodle.errors
 14import lms.model.backend
 15import lms.model.constants
 16import lms.util.net
 17
 18_logger = logging.getLogger(__name__)
 19
 20ROLE_MAPPING: typing.Dict[str, lms.model.users.CourseRole] = {
 21    "guest": lms.model.users.CourseRole.OTHER,
 22    "student": lms.model.users.CourseRole.STUDENT,
 23    "non-editing teacher": lms.model.users.CourseRole.GRADER,
 24    "teacher": lms.model.users.CourseRole.ADMIN,
 25    "manager": lms.model.users.CourseRole.OWNER,
 26}
 27
 28# Moodle shows 5000 users per page when asked to fetch all results.
 29RESULTS_PER_PAGE: int = 5000
 30
 31class MoodleBackend(lms.model.backend.APIBackend):
 32    """ An API backend for the Moodle LMS. """
 33
 34    def __init__(self,
 35            **kwargs: typing.Any) -> None:
 36        super().__init__(**kwargs)
 37
 38        assert(self.config.backend_type == lms.model.constants.BackendType.MOODLE)
 39
 40        if (self.config.auth_user is None):
 41            raise ValueError("Moodle backends require a username.")
 42
 43        self.auth_user: str = self.config.auth_user
 44        """
 45        The user to authenticate with.
 46        This is set in config and compied for type checking.
 47        """
 48
 49        if (self.config.auth_password is None):
 50            raise ValueError("Moodle backends require a password.")
 51
 52        self.auth_password: str = self.config.auth_password.cleartext
 53        """
 54        The (cleartext) password to authenticate with.
 55        This is set in config and compied for type checking.
 56        """
 57
 58        self._session_headers: typing.Union[typing.Dict[str, typing.Any], None] = None
 59        """ The headers (e.g., cookies) for our logged in Moodle session. """
 60
 61    def _get_edit_mode_page(self, url: str, **kwargs: typing.Any) -> typing.Union[requests.Response, None]:
 62        """
 63        Tries to fetch the page at the given url with edit mode enabled.
 64        Returns the response with edit mode enabled, or None if unable to get the edit mode version of the page.
 65        """
 66
 67        try:
 68            response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers(), **kwargs)
 69        except requests.exceptions.HTTPError:
 70            return None
 71
 72        sesskey_match = re.search(r'"sesskey":"([^"]+)"', response.text)
 73        if (sesskey_match is None):
 74            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 75
 76        sesskey = sesskey_match.group(1)
 77
 78        document = bs4.BeautifulSoup(response.text, 'html.parser')
 79
 80        element = document.select_one('input[name=setmode]')
 81        if (element is None):
 82            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 83
 84        context_str = element.get('data-context', None)
 85        if (context_str is None):
 86            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 87
 88        if (not isinstance(context_str, str)):
 89            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 90
 91        context = int(context_str)
 92
 93        params = {
 94            'sesskey': sesskey,
 95            'info': 'core_change_editmode',
 96        }
 97
 98        data = [
 99            {
100                'index': 0,
101                'methodname': 'core_change_editmode',
102                'args': {
103                    'setmode': True,
104                    'context': context,
105                },
106            }
107        ]
108
109        response, _ = edq.net.request.make_post(
110            f"{self.server}/lib/ajax/service.php",
111            additional_requests_options = {'params': params},
112            data = json.dumps(data),
113            headers = self.get_standard_headers(),
114        )
115
116        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers(), **kwargs)
117
118        return response
119
120    def reset_connection(self) -> None:
121        self._session_headers = None
122
123    def get_standard_headers(self, write: bool = False) -> typing.Dict[str, str]:
124        headers = super().get_standard_headers(write)
125
126        if (self._session_headers is not None):
127            headers.update(self._session_headers)
128
129        return headers
130
131    def _parse_cookies(self, response: requests.Response) -> typing.Dict[str, typing.Any]:
132        """
133        Parse Moodle cookies.
134        Return fake cookies when testing.
135        """
136
137        if (self.is_testing()):
138            return {
139                'moodlesession': 'testing-moodle-session',
140                'moodleid1_': 'testing-moodle-id',
141            }
142
143        return lms.util.net.parse_cookies(response.headers.get('set-cookie', None))
144
145    def _login(self, update_server: bool = True) -> None:
146        """
147        Try to login to the Moodle server.
148        If `update_server` is true, then this may try to update the backend's server location if redirected by the Moodle server.
149        """
150
151        # Check if we are already logged in.
152        if (self._session_headers is not None):
153            return
154
155        response, body = edq.net.request.make_get(self.server + '/login/index.php')
156        cookies = self._parse_cookies(response)
157
158        new_cookies = {
159            'MoodleSession': cookies['moodlesession'],
160        }
161        text_cookies = '; '.join(['='.join(items) for items in new_cookies.items()])
162
163        # Parse the login token from the page HTML.
164        document = bs4.BeautifulSoup(body, 'html.parser')
165        token = document.select('input[name="logintoken"]')[0]['value']
166
167        headers = {
168            'cookie': text_cookies,
169            'host': urllib.parse.urlparse(self.server).netloc,
170        }
171
172        data = {
173            'logintoken': token,
174            'username': self.auth_user,
175            'password': self.auth_password,
176        }
177
178        response, _ = edq.net.request.make_post(self.server + '/login/index.php',
179                headers = headers, data = data,
180                allow_redirects = False)
181
182        # Check for a successful login.
183        cookies = self._parse_cookies(response)
184        if ('moodleid1_' in cookies):
185            self._session_headers = {
186                'cookie': response.headers.get('set-cookie', None),
187                # Insert a header to identify the user.
188                'edq-lms-moodle-user': self.auth_user,
189            }
190
191            return
192
193        # Login Failed
194
195        # The specified server/host needs to match exactly what the Moodle server wants it to be,
196        # e.g., `127.0.0.1` does not work when the server wants the host to be `localhost`.
197        # If these do not match, we will get a redirect here.
198        # Use this redirect to discover the correct server.
199        location = response.headers.get('location', None)
200        if (update_server and (location is not None) and (not location.startswith(self.server))):
201            parts = urllib.parse.urlparse(location)
202            host = f"{parts.scheme}://{parts.netloc}"
203
204            _logger.debug(("Mismatch in the client-specified server ('%s') and server-requested host ('%s')."
205                    + " To avoid extra requests, update the server (e.g., `--server`) to match the host."),
206                    self.server, host)
207
208            # Update the server and try to login again (without updating the server again (to avoid loops)).
209            self.server = host
210            self._login(update_server = False)
211            return
212
213        raise ValueError(f"Could not log into Moodle server ({self.server}) with user '{self.auth_user}'. Is username/password correct?")
214
215    def courses_list(self,
216            **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]:
217        self._login()
218
219        url = self.server + "/user/profile.php"
220        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
221
222        document = bs4.BeautifulSoup(response.text, 'html.parser')
223        cards = document.select('div.card-body')
224
225        node = None
226        for card in cards:
227            text = card.get_text()
228            if (text.startswith("Course details")):
229                node = card
230                break
231
232        if (node is None):
233            return []
234
235        links = node.select('a')
236
237        courses = []
238        for link in links:
239            name = link.get_text()
240
241            href = link.get('href', None)
242            if (href is None):
243                continue
244
245            id = str(href).rsplit("=", maxsplit = 1)[-1]
246
247            courses.append(lms.model.courses.Course(
248                id = id,
249                name = name,
250            ))
251
252        return sorted(courses)
253
254    def courses_users_list(self,
255            course_id: str,
256            **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]:
257        self._login()
258
259        url = f"{self.server}/user/index.php?id={course_id}&perpage={RESULTS_PER_PAGE}"
260        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
261
262        document = bs4.BeautifulSoup(response.text, 'html.parser')
263
264        headers = document.select('table#participants thead tr th')
265        # { course_user_attribute (e.g. name): column class, ... }
266        classes = {}
267        for header in headers:
268            column_classes = header.get('class', None)
269            if (column_classes is None):
270                continue
271
272            # Parse and store the column's class (e.g. "c0").
273            # This class is referenced when storing corresponding course user data.
274            if (isinstance(column_classes, str)):
275                column_class = column_classes
276            else:
277                if ('header' in column_classes):
278                    column_classes.remove('header')
279
280                if (len(column_classes) != 1):
281                    continue
282
283                column_class = column_classes[0]
284
285            elements = header.select('div.commands a')
286            for element in elements:
287                attribute = element.get('data-column', None)
288                if (attribute is None):
289                    continue
290
291                classes[attribute] = column_class
292
293        rows = document.select('table#participants tbody tr:not(.emptyrow)')
294
295        users = []
296        for row in rows:
297            try:
298                id = row.select_one('.cell input[type="checkbox"]').get('id', None).removeprefix('user')  # type: ignore[union-attr]
299                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
300                email = row.select_one(f'.cell.{classes["email"]}').get_text()  # type: ignore[union-attr]
301                raw_role = row.select_one(f'.cell.{classes["roles"]} span a').get_text().strip().lower()  # type: ignore[union-attr]
302            except AttributeError as ex:
303                raise lms.backend.moodle.errors.MoodleAPIBreakageError() from ex
304
305            # HACK(JK): Moodle does not allow the Guest role when loading test data, so we patch the guest role during testing.
306            if (email == 'course-other@test.edulinq.org'):
307                raw_role = "guest"
308
309            users.append(lms.model.users.CourseUser(
310                id = id,
311                name = name,
312                email = email,
313                raw_role = raw_role,
314                role = ROLE_MAPPING.get(raw_role, None),
315            ))
316
317        return users
318
319    def courses_assignments_list(self,
320            course_id: str,
321            **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]:
322        self._login()
323
324        url = f"{self.server}/grade/report/grader/index.php?id={course_id}"
325
326        # Attempt to enable edit mode on the gradebook page.
327        response = self._get_edit_mode_page(url, enable_edit_mode = True)
328
329        # We expect graders to have edit mode access.
330        # If the edit mode request fails, we fall back to the non-grader gradebook page.
331        if (response is not None):
332            return self._fetch_assignments_grader(response)
333
334        return self._fetch_assignments_non_grader(course_id)
335
336    def _fetch_assignments_grader(self, response: requests.Response) -> typing.List[lms.model.assignments.Assignment]:
337        """
338        Fetch assignment data for users with grader permissions.
339        """
340
341        assignments = []
342
343        document = bs4.BeautifulSoup(response.text, 'html.parser')
344
345        activities = document.select('table#user-grades th.item')
346        for activity in activities:
347            # Parse and store the column's class (e.g. "c0").
348            target_class = None
349
350            column_classes = activity.get('class', None)
351            if (column_classes is None):
352                raise lms.backend.moodle.errors.MoodleAPIBreakageError()
353
354            for column_class in column_classes:
355                if (re.search(r'^c\d+$', column_class) is not None):
356                    target_class = column_class
357                    break
358
359            try:
360                id = str(activity.get('data-itemid', None))
361                name = str(activity.select_one('a.gradeitemheader').get_text())  # type: ignore[union-attr]
362
363                points_possible_str = document.select_one(f'td.{target_class} input').get('max', None)  # type: ignore[union-attr]
364                if (not isinstance(points_possible_str, str)):
365                    points_possible = 0.0
366                else:
367                    points_possible = float(points_possible_str)
368            except AttributeError as ex:
369                raise lms.backend.moodle.errors.MoodleAPIBreakageError() from ex
370
371            assignments.append(lms.model.assignments.Assignment(
372                id = id,
373                name = name,
374                points_possible = points_possible,
375            ))
376
377        return assignments
378
379    def _fetch_assignments_non_grader(self, course_id: str) -> typing.List[lms.model.assignments.Assignment]:
380        """
381        Fetch assignment data for users without grader permissions.
382        """
383
384        assignments = []
385
386        url = f"{self.server}/grade/report/user/index.php?id={course_id}"
387        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
388
389        document = bs4.BeautifulSoup(response.text, 'html.parser')
390
391        activities: typing.List[bs4.Tag] = list(document.find_all('tr[class]:not([class=""]):not(.spacer):not(.lastrow)'))
392        for activity in activities:
393            try:
394                id = str(activity.select_one('th').get('id', None).split('_')[1])  # type: ignore[union-attr]
395                name = str(activity.select_one('th a').get_text())  # type: ignore[union-attr]
396                points_possible = float(activity.select_one('td.column-range').get_text().split('–')[1])  # type: ignore[union-attr]
397            except AttributeError as ex:
398                raise lms.backend.moodle.errors.MoodleAPIBreakageError() from ex
399
400            assignments.append(lms.model.assignments.Assignment(
401                id = id,
402                name = name,
403                points_possible = points_possible,
404            ))
405
406        return assignments
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):
 32class MoodleBackend(lms.model.backend.APIBackend):
 33    """ An API backend for the Moodle LMS. """
 34
 35    def __init__(self,
 36            **kwargs: typing.Any) -> None:
 37        super().__init__(**kwargs)
 38
 39        assert(self.config.backend_type == lms.model.constants.BackendType.MOODLE)
 40
 41        if (self.config.auth_user is None):
 42            raise ValueError("Moodle backends require a username.")
 43
 44        self.auth_user: str = self.config.auth_user
 45        """
 46        The user to authenticate with.
 47        This is set in config and compied for type checking.
 48        """
 49
 50        if (self.config.auth_password is None):
 51            raise ValueError("Moodle backends require a password.")
 52
 53        self.auth_password: str = self.config.auth_password.cleartext
 54        """
 55        The (cleartext) password to authenticate with.
 56        This is set in config and compied for type checking.
 57        """
 58
 59        self._session_headers: typing.Union[typing.Dict[str, typing.Any], None] = None
 60        """ The headers (e.g., cookies) for our logged in Moodle session. """
 61
 62    def _get_edit_mode_page(self, url: str, **kwargs: typing.Any) -> typing.Union[requests.Response, None]:
 63        """
 64        Tries to fetch the page at the given url with edit mode enabled.
 65        Returns the response with edit mode enabled, or None if unable to get the edit mode version of the page.
 66        """
 67
 68        try:
 69            response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers(), **kwargs)
 70        except requests.exceptions.HTTPError:
 71            return None
 72
 73        sesskey_match = re.search(r'"sesskey":"([^"]+)"', response.text)
 74        if (sesskey_match is None):
 75            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 76
 77        sesskey = sesskey_match.group(1)
 78
 79        document = bs4.BeautifulSoup(response.text, 'html.parser')
 80
 81        element = document.select_one('input[name=setmode]')
 82        if (element is None):
 83            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 84
 85        context_str = element.get('data-context', None)
 86        if (context_str is None):
 87            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 88
 89        if (not isinstance(context_str, str)):
 90            raise lms.backend.moodle.errors.MoodleAPIBreakageError()
 91
 92        context = int(context_str)
 93
 94        params = {
 95            'sesskey': sesskey,
 96            'info': 'core_change_editmode',
 97        }
 98
 99        data = [
100            {
101                'index': 0,
102                'methodname': 'core_change_editmode',
103                'args': {
104                    'setmode': True,
105                    'context': context,
106                },
107            }
108        ]
109
110        response, _ = edq.net.request.make_post(
111            f"{self.server}/lib/ajax/service.php",
112            additional_requests_options = {'params': params},
113            data = json.dumps(data),
114            headers = self.get_standard_headers(),
115        )
116
117        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers(), **kwargs)
118
119        return response
120
121    def reset_connection(self) -> None:
122        self._session_headers = None
123
124    def get_standard_headers(self, write: bool = False) -> typing.Dict[str, str]:
125        headers = super().get_standard_headers(write)
126
127        if (self._session_headers is not None):
128            headers.update(self._session_headers)
129
130        return headers
131
132    def _parse_cookies(self, response: requests.Response) -> typing.Dict[str, typing.Any]:
133        """
134        Parse Moodle cookies.
135        Return fake cookies when testing.
136        """
137
138        if (self.is_testing()):
139            return {
140                'moodlesession': 'testing-moodle-session',
141                'moodleid1_': 'testing-moodle-id',
142            }
143
144        return lms.util.net.parse_cookies(response.headers.get('set-cookie', None))
145
146    def _login(self, update_server: bool = True) -> None:
147        """
148        Try to login to the Moodle server.
149        If `update_server` is true, then this may try to update the backend's server location if redirected by the Moodle server.
150        """
151
152        # Check if we are already logged in.
153        if (self._session_headers is not None):
154            return
155
156        response, body = edq.net.request.make_get(self.server + '/login/index.php')
157        cookies = self._parse_cookies(response)
158
159        new_cookies = {
160            'MoodleSession': cookies['moodlesession'],
161        }
162        text_cookies = '; '.join(['='.join(items) for items in new_cookies.items()])
163
164        # Parse the login token from the page HTML.
165        document = bs4.BeautifulSoup(body, 'html.parser')
166        token = document.select('input[name="logintoken"]')[0]['value']
167
168        headers = {
169            'cookie': text_cookies,
170            'host': urllib.parse.urlparse(self.server).netloc,
171        }
172
173        data = {
174            'logintoken': token,
175            'username': self.auth_user,
176            'password': self.auth_password,
177        }
178
179        response, _ = edq.net.request.make_post(self.server + '/login/index.php',
180                headers = headers, data = data,
181                allow_redirects = False)
182
183        # Check for a successful login.
184        cookies = self._parse_cookies(response)
185        if ('moodleid1_' in cookies):
186            self._session_headers = {
187                'cookie': response.headers.get('set-cookie', None),
188                # Insert a header to identify the user.
189                'edq-lms-moodle-user': self.auth_user,
190            }
191
192            return
193
194        # Login Failed
195
196        # The specified server/host needs to match exactly what the Moodle server wants it to be,
197        # e.g., `127.0.0.1` does not work when the server wants the host to be `localhost`.
198        # If these do not match, we will get a redirect here.
199        # Use this redirect to discover the correct server.
200        location = response.headers.get('location', None)
201        if (update_server and (location is not None) and (not location.startswith(self.server))):
202            parts = urllib.parse.urlparse(location)
203            host = f"{parts.scheme}://{parts.netloc}"
204
205            _logger.debug(("Mismatch in the client-specified server ('%s') and server-requested host ('%s')."
206                    + " To avoid extra requests, update the server (e.g., `--server`) to match the host."),
207                    self.server, host)
208
209            # Update the server and try to login again (without updating the server again (to avoid loops)).
210            self.server = host
211            self._login(update_server = False)
212            return
213
214        raise ValueError(f"Could not log into Moodle server ({self.server}) with user '{self.auth_user}'. Is username/password correct?")
215
216    def courses_list(self,
217            **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]:
218        self._login()
219
220        url = self.server + "/user/profile.php"
221        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
222
223        document = bs4.BeautifulSoup(response.text, 'html.parser')
224        cards = document.select('div.card-body')
225
226        node = None
227        for card in cards:
228            text = card.get_text()
229            if (text.startswith("Course details")):
230                node = card
231                break
232
233        if (node is None):
234            return []
235
236        links = node.select('a')
237
238        courses = []
239        for link in links:
240            name = link.get_text()
241
242            href = link.get('href', None)
243            if (href is None):
244                continue
245
246            id = str(href).rsplit("=", maxsplit = 1)[-1]
247
248            courses.append(lms.model.courses.Course(
249                id = id,
250                name = name,
251            ))
252
253        return sorted(courses)
254
255    def courses_users_list(self,
256            course_id: str,
257            **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]:
258        self._login()
259
260        url = f"{self.server}/user/index.php?id={course_id}&perpage={RESULTS_PER_PAGE}"
261        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
262
263        document = bs4.BeautifulSoup(response.text, 'html.parser')
264
265        headers = document.select('table#participants thead tr th')
266        # { course_user_attribute (e.g. name): column class, ... }
267        classes = {}
268        for header in headers:
269            column_classes = header.get('class', None)
270            if (column_classes is None):
271                continue
272
273            # Parse and store the column's class (e.g. "c0").
274            # This class is referenced when storing corresponding course user data.
275            if (isinstance(column_classes, str)):
276                column_class = column_classes
277            else:
278                if ('header' in column_classes):
279                    column_classes.remove('header')
280
281                if (len(column_classes) != 1):
282                    continue
283
284                column_class = column_classes[0]
285
286            elements = header.select('div.commands a')
287            for element in elements:
288                attribute = element.get('data-column', None)
289                if (attribute is None):
290                    continue
291
292                classes[attribute] = column_class
293
294        rows = document.select('table#participants tbody tr:not(.emptyrow)')
295
296        users = []
297        for row in rows:
298            try:
299                id = row.select_one('.cell input[type="checkbox"]').get('id', None).removeprefix('user')  # type: ignore[union-attr]
300                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
301                email = row.select_one(f'.cell.{classes["email"]}').get_text()  # type: ignore[union-attr]
302                raw_role = row.select_one(f'.cell.{classes["roles"]} span a').get_text().strip().lower()  # type: ignore[union-attr]
303            except AttributeError as ex:
304                raise lms.backend.moodle.errors.MoodleAPIBreakageError() from ex
305
306            # HACK(JK): Moodle does not allow the Guest role when loading test data, so we patch the guest role during testing.
307            if (email == 'course-other@test.edulinq.org'):
308                raw_role = "guest"
309
310            users.append(lms.model.users.CourseUser(
311                id = id,
312                name = name,
313                email = email,
314                raw_role = raw_role,
315                role = ROLE_MAPPING.get(raw_role, None),
316            ))
317
318        return users
319
320    def courses_assignments_list(self,
321            course_id: str,
322            **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]:
323        self._login()
324
325        url = f"{self.server}/grade/report/grader/index.php?id={course_id}"
326
327        # Attempt to enable edit mode on the gradebook page.
328        response = self._get_edit_mode_page(url, enable_edit_mode = True)
329
330        # We expect graders to have edit mode access.
331        # If the edit mode request fails, we fall back to the non-grader gradebook page.
332        if (response is not None):
333            return self._fetch_assignments_grader(response)
334
335        return self._fetch_assignments_non_grader(course_id)
336
337    def _fetch_assignments_grader(self, response: requests.Response) -> typing.List[lms.model.assignments.Assignment]:
338        """
339        Fetch assignment data for users with grader permissions.
340        """
341
342        assignments = []
343
344        document = bs4.BeautifulSoup(response.text, 'html.parser')
345
346        activities = document.select('table#user-grades th.item')
347        for activity in activities:
348            # Parse and store the column's class (e.g. "c0").
349            target_class = None
350
351            column_classes = activity.get('class', None)
352            if (column_classes is None):
353                raise lms.backend.moodle.errors.MoodleAPIBreakageError()
354
355            for column_class in column_classes:
356                if (re.search(r'^c\d+$', column_class) is not None):
357                    target_class = column_class
358                    break
359
360            try:
361                id = str(activity.get('data-itemid', None))
362                name = str(activity.select_one('a.gradeitemheader').get_text())  # type: ignore[union-attr]
363
364                points_possible_str = document.select_one(f'td.{target_class} input').get('max', None)  # type: ignore[union-attr]
365                if (not isinstance(points_possible_str, str)):
366                    points_possible = 0.0
367                else:
368                    points_possible = float(points_possible_str)
369            except AttributeError as ex:
370                raise lms.backend.moodle.errors.MoodleAPIBreakageError() from ex
371
372            assignments.append(lms.model.assignments.Assignment(
373                id = id,
374                name = name,
375                points_possible = points_possible,
376            ))
377
378        return assignments
379
380    def _fetch_assignments_non_grader(self, course_id: str) -> typing.List[lms.model.assignments.Assignment]:
381        """
382        Fetch assignment data for users without grader permissions.
383        """
384
385        assignments = []
386
387        url = f"{self.server}/grade/report/user/index.php?id={course_id}"
388        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
389
390        document = bs4.BeautifulSoup(response.text, 'html.parser')
391
392        activities: typing.List[bs4.Tag] = list(document.find_all('tr[class]:not([class=""]):not(.spacer):not(.lastrow)'))
393        for activity in activities:
394            try:
395                id = str(activity.select_one('th').get('id', None).split('_')[1])  # type: ignore[union-attr]
396                name = str(activity.select_one('th a').get_text())  # type: ignore[union-attr]
397                points_possible = float(activity.select_one('td.column-range').get_text().split('–')[1])  # type: ignore[union-attr]
398            except AttributeError as ex:
399                raise lms.backend.moodle.errors.MoodleAPIBreakageError() from ex
400
401            assignments.append(lms.model.assignments.Assignment(
402                id = id,
403                name = name,
404                points_possible = points_possible,
405            ))
406
407        return assignments

An API backend for the Moodle LMS.

MoodleBackend(**kwargs: Any)
35    def __init__(self,
36            **kwargs: typing.Any) -> None:
37        super().__init__(**kwargs)
38
39        assert(self.config.backend_type == lms.model.constants.BackendType.MOODLE)
40
41        if (self.config.auth_user is None):
42            raise ValueError("Moodle backends require a username.")
43
44        self.auth_user: str = self.config.auth_user
45        """
46        The user to authenticate with.
47        This is set in config and compied for type checking.
48        """
49
50        if (self.config.auth_password is None):
51            raise ValueError("Moodle backends require a password.")
52
53        self.auth_password: str = self.config.auth_password.cleartext
54        """
55        The (cleartext) password to authenticate with.
56        This is set in config and compied for type checking.
57        """
58
59        self._session_headers: typing.Union[typing.Dict[str, typing.Any], None] = None
60        """ 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:
121    def reset_connection(self) -> None:
122        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]:
124    def get_standard_headers(self, write: bool = False) -> typing.Dict[str, str]:
125        headers = super().get_standard_headers(write)
126
127        if (self._session_headers is not None):
128            headers.update(self._session_headers)
129
130        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]:
216    def courses_list(self,
217            **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]:
218        self._login()
219
220        url = self.server + "/user/profile.php"
221        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
222
223        document = bs4.BeautifulSoup(response.text, 'html.parser')
224        cards = document.select('div.card-body')
225
226        node = None
227        for card in cards:
228            text = card.get_text()
229            if (text.startswith("Course details")):
230                node = card
231                break
232
233        if (node is None):
234            return []
235
236        links = node.select('a')
237
238        courses = []
239        for link in links:
240            name = link.get_text()
241
242            href = link.get('href', None)
243            if (href is None):
244                continue
245
246            id = str(href).rsplit("=", maxsplit = 1)[-1]
247
248            courses.append(lms.model.courses.Course(
249                id = id,
250                name = name,
251            ))
252
253        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]:
255    def courses_users_list(self,
256            course_id: str,
257            **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]:
258        self._login()
259
260        url = f"{self.server}/user/index.php?id={course_id}&perpage={RESULTS_PER_PAGE}"
261        response, _ = edq.net.request.make_get(url, headers = self.get_standard_headers())
262
263        document = bs4.BeautifulSoup(response.text, 'html.parser')
264
265        headers = document.select('table#participants thead tr th')
266        # { course_user_attribute (e.g. name): column class, ... }
267        classes = {}
268        for header in headers:
269            column_classes = header.get('class', None)
270            if (column_classes is None):
271                continue
272
273            # Parse and store the column's class (e.g. "c0").
274            # This class is referenced when storing corresponding course user data.
275            if (isinstance(column_classes, str)):
276                column_class = column_classes
277            else:
278                if ('header' in column_classes):
279                    column_classes.remove('header')
280
281                if (len(column_classes) != 1):
282                    continue
283
284                column_class = column_classes[0]
285
286            elements = header.select('div.commands a')
287            for element in elements:
288                attribute = element.get('data-column', None)
289                if (attribute is None):
290                    continue
291
292                classes[attribute] = column_class
293
294        rows = document.select('table#participants tbody tr:not(.emptyrow)')
295
296        users = []
297        for row in rows:
298            try:
299                id = row.select_one('.cell input[type="checkbox"]').get('id', None).removeprefix('user')  # type: ignore[union-attr]
300                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
301                email = row.select_one(f'.cell.{classes["email"]}').get_text()  # type: ignore[union-attr]
302                raw_role = row.select_one(f'.cell.{classes["roles"]} span a').get_text().strip().lower()  # type: ignore[union-attr]
303            except AttributeError as ex:
304                raise lms.backend.moodle.errors.MoodleAPIBreakageError() from ex
305
306            # HACK(JK): Moodle does not allow the Guest role when loading test data, so we patch the guest role during testing.
307            if (email == 'course-other@test.edulinq.org'):
308                raw_role = "guest"
309
310            users.append(lms.model.users.CourseUser(
311                id = id,
312                name = name,
313                email = email,
314                raw_role = raw_role,
315                role = ROLE_MAPPING.get(raw_role, None),
316            ))
317
318        return users

List the users associated with the given course.

def courses_assignments_list( self, course_id: str, **kwargs: Any) -> List[lms.model.assignments.Assignment]:
320    def courses_assignments_list(self,
321            course_id: str,
322            **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]:
323        self._login()
324
325        url = f"{self.server}/grade/report/grader/index.php?id={course_id}"
326
327        # Attempt to enable edit mode on the gradebook page.
328        response = self._get_edit_mode_page(url, enable_edit_mode = True)
329
330        # We expect graders to have edit mode access.
331        # If the edit mode request fails, we fall back to the non-grader gradebook page.
332        if (response is not None):
333            return self._fetch_assignments_grader(response)
334
335        return self._fetch_assignments_non_grader(course_id)

List the assignments 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_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