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
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_password: str
The (cleartext) password to authenticate with. This is set in config and compied for type checking.
def
reset_connection(self) -> 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.
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.
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