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

An API backend for the Moodle LMS.

MoodleBackend( server: str, auth_user: Optional[str] = None, auth_password: Optional[str] = None, **kwargs: Any)
32    def __init__(self,
33            server: str,
34            auth_user: typing.Union[str, None] = None,
35            auth_password: typing.Union[str, None] = None,
36            **kwargs: typing.Any) -> None:
37        super().__init__(server, lms.model.constants.BACKEND_TYPE_MOODLE, **kwargs)
38
39        if (auth_user is None):
40            raise ValueError("Moodle backends require a username.")
41
42        if (auth_password is None):
43            raise ValueError("Moodle backends require a password.")
44
45        self._username = auth_user
46        """ The username to authenticate with. """
47
48        self._password = auth_password
49        """ The password to authenticate with. """
50
51        self._session_headers: typing.Union[typing.Dict[str, typing.Any], None] = None
52        """ The headers (e.g., cookies) for our logged in Moodle session. """
def courses_list(self, **kwargs: Any) -> List[lms.model.courses.Course]:
137    def courses_list(self,
138            **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]:
139        self._login()
140
141        url = self.server + "/user/profile.php"
142        response, _ = edq.net.request.make_get(url, headers = self._session_headers)
143
144        document = bs4.BeautifulSoup(response.text, 'html.parser')
145        cards = document.select('div.card-body')
146
147        node = None
148        for card in cards:
149            text = card.get_text()
150            if (text.startswith("Course details")):
151                node = card
152                break
153
154        if (node is None):
155            return []
156
157        links = node.select('a')
158
159        courses = []
160        for link in links:
161            name = link.get_text()
162
163            href = link.get('href', None)
164            if (href is None):
165                continue
166
167            id = str(href).rsplit("=", maxsplit = 1)[-1]
168
169            courses.append(lms.model.courses.Course(
170                id = id,
171                name = name,
172            ))
173
174        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]:
176    def courses_users_list(self,
177            course_id: str,
178            **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]:
179        url = f"{self.server}/user/index.php?id={course_id}&perpage={RESULTS_PER_PAGE}"
180        response, _ = edq.net.request.make_get(url, headers = self._session_headers)
181
182        document = bs4.BeautifulSoup(response.text, 'html.parser')
183
184        headers = document.select('table#participants thead tr th')
185        # { course_user_attribute (e.g. name): column class, ... }
186        classes = {}
187        for header in headers:
188            column_classes = header.get('class', None)
189            if (column_classes is None):
190                continue
191
192            # Parse and store the column's class (e.g. "c0").
193            # This class is referenced when storing corresponding course user data.
194            if (isinstance(column_classes, str)):
195                column_class = column_classes
196            else:
197                if ('header' in column_classes):
198                    column_classes.remove('header')
199
200                if (len(column_classes) != 1):
201                    continue
202
203                column_class = column_classes[0]
204
205            elements = header.select('div.commands a')
206            for element in elements:
207                attribute = element.get('data-column', None)
208                if (attribute is None):
209                    continue
210
211                classes[attribute] = column_class
212
213        rows = document.select('table#participants tbody tr:not(.emptyrow)')
214
215        users = []
216        for row in rows:
217            try:
218                id = row.select_one('.cell input[type="checkbox"]').get('id', None).removeprefix('user')  # type: ignore[union-attr]
219                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
220                email = row.select_one(f'.cell.{classes["email"]}').get_text()  # type: ignore[union-attr]
221                raw_role = row.select_one(f'.cell.{classes["roles"]} span a').get_text().strip().lower()  # type: ignore[union-attr]
222            except AttributeError as _:
223                _logger.warning("Unable to list users. Moodle data structure has changed. Contact project developers.")
224                continue
225
226            # HACK(JK): Moodle does not allow the Guest role when loading test data, so we patch the guest role during testing.
227            if (email == 'course-other@test.edulinq.org'):
228                raw_role = "guest"
229
230            users.append(lms.model.users.CourseUser(
231                id = id,
232                name = name,
233                email = email,
234                raw_role = raw_role,
235                role = ROLE_MAPPING.get(raw_role, None),
236            ))
237
238        return users

List the users associated with the given course.

Inherited Members
lms.model.backend.APIBackend
server
backend_type
testing
is_testing
get_standard_headers
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_get
courses_quizzes_fetch
courses_quizzes_list
courses_quizzes_resolve_and_list
courses_quizzes_groups_get
courses_quizzes_groups_fetch
courses_quizzes_groups_list
courses_quizzes_groups_resolve_and_list
courses_quizzes_questions_get
courses_quizzes_questions_fetch
courses_quizzes_questions_list
courses_quizzes_questions_resolve_and_list
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_quiz_query
parse_quiz_queries
parse_quiz_question_query
parse_quiz_question_queries
parse_quiz_question_group_query
parse_quiz_question_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