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
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. """
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.
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