lms.model.backend
1import logging 2import typing 3 4import edq.util.parse 5 6import lms.model.assignments 7import lms.model.constants 8import lms.model.courses 9import lms.model.groups 10import lms.model.groupsets 11import lms.model.query 12import lms.model.scores 13import lms.model.users 14 15_logger = logging.getLogger(__name__) 16 17class APIBackend(): 18 """ 19 API backends provide a unified interface to an LMS. 20 21 Note that instead of using an abstract class, 22 methods will raise a NotImplementedError by default. 23 This will allow child backends to fill in as much functionality as they can, 24 while still leaving gaps where they are incomplete or impossible. 25 """ 26 27 _testing_override: typing.Union[bool, None] = None 28 """ A top-level override to control testing status. """ 29 30 def __init__(self, 31 server: str, 32 backend_type: str, 33 testing: typing.Union[bool, str] = False, 34 **kwargs: typing.Any) -> None: 35 self.server: str = server 36 """ The server this backend will connect to. """ 37 38 self.backend_type: str = backend_type 39 """ 40 The type for this backend. 41 Should be set by the child class. 42 """ 43 44 parsed_testing = edq.util.parse.boolean(testing) 45 if (APIBackend._testing_override is not None): 46 parsed_testing = APIBackend._testing_override 47 48 self.testing: bool = parsed_testing 49 """ True if the backend is being used for a test. """ 50 51 # Core Methods 52 53 def is_testing(self) -> bool: 54 """ Check if this backend is in testing mode. """ 55 56 return self.testing 57 58 def get_standard_headers(self) -> typing.Dict[str, str]: 59 """ 60 Get standard headers for this backend. 61 Children should take care to set the write header when performing a write operation. 62 """ 63 64 return { 65 lms.model.constants.HEADER_KEY_BACKEND: self.backend_type, 66 lms.model.constants.HEADER_KEY_WRITE: 'false', 67 } 68 69 def not_found(self, operation: str, identifiers: typing.Dict[str, typing.Any]) -> None: 70 """ 71 Called when the backend was unable to find some object. 72 This will only be called when a requested object is not found, 73 e.g., a user requested by ID is not found. 74 This is not called when a list naturally returns zero results, 75 or when a query does not match any items. 76 """ 77 78 _logger.warning("Object not found during operation: '%s'. Identifiers: %s.", operation, identifiers) 79 80 # API Methods 81 82 def courses_get(self, 83 course_queries: typing.Collection[lms.model.courses.CourseQuery], 84 **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]: 85 """ 86 Get the specified courses associated with the given course. 87 """ 88 89 if (len(course_queries) == 0): 90 return [] 91 92 courses = self.courses_list(**kwargs) 93 94 matches = [] 95 for course in sorted(courses): 96 for query in course_queries: 97 if (query.match(course)): 98 matches.append(course) 99 break 100 101 return sorted(matches) 102 103 def courses_fetch(self, 104 course_id: str, 105 **kwargs: typing.Any) -> typing.Union[lms.model.courses.Course, None]: 106 """ 107 Fetch a single course associated with the context user. 108 Return None if no matching course is found. 109 110 By default, this will just do a list and choose the relevant record. 111 Specific backends may override this if there are performance concerns. 112 """ 113 114 courses = self.courses_list(**kwargs) 115 for course in courses: 116 if (course.id == course_id): 117 return course 118 119 return None 120 121 def courses_list(self, 122 **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]: 123 """ 124 List the courses associated with the context user. 125 """ 126 127 raise NotImplementedError('courses_list') 128 129 def courses_assignments_get(self, 130 course_query: lms.model.courses.CourseQuery, 131 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 132 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 133 """ 134 Get the specified assignments associated with the given course. 135 """ 136 137 if (len(assignment_queries) == 0): 138 return [] 139 140 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 141 142 assignments = sorted(self.courses_assignments_list(resolved_course_query.get_id(), **kwargs)) 143 assignment_queries = sorted(assignment_queries) 144 145 matches = [] 146 for assignment in assignments: 147 for query in assignment_queries: 148 if (query.match(assignment)): 149 matches.append(assignment) 150 break 151 152 return matches 153 154 def courses_assignments_fetch(self, 155 course_id: str, 156 assignment_id: str, 157 **kwargs: typing.Any) -> typing.Union[lms.model.assignments.Assignment, None]: 158 """ 159 Fetch a single assignment associated with the given course. 160 Return None if no matching assignment is found. 161 162 By default, this will just do a list and choose the relevant record. 163 Specific backends may override this if there are performance concerns. 164 """ 165 166 assignments = self.courses_assignments_list(course_id, **kwargs) 167 for assignment in sorted(assignments): 168 if (assignment.id == assignment_id): 169 return assignment 170 171 return None 172 173 def courses_assignments_list(self, 174 course_id: str, 175 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 176 """ 177 List the assignments associated with the given course. 178 """ 179 180 raise NotImplementedError('courses_assignments_list') 181 182 def courses_assignments_resolve_and_list(self, 183 course_query: lms.model.courses.CourseQuery, 184 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 185 """ 186 List the assignments associated with the given course. 187 """ 188 189 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 190 return sorted(self.courses_assignments_list(resolved_course_query.get_id(), **kwargs)) 191 192 def courses_assignments_scores_get(self, 193 course_query: lms.model.courses.CourseQuery, 194 assignment_query: lms.model.assignments.AssignmentQuery, 195 user_queries: typing.Collection[lms.model.users.UserQuery], 196 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 197 """ 198 Get the scores associated with the given assignment query and user queries. 199 """ 200 201 if (len(user_queries) == 0): 202 return [] 203 204 scores = self.courses_assignments_scores_resolve_and_list(course_query, assignment_query, **kwargs) 205 206 matches = [] 207 for score in scores: 208 for user_query in user_queries: 209 if (user_query.match(score.user)): 210 matches.append(score) 211 212 return sorted(matches) 213 214 def courses_assignments_scores_fetch(self, 215 course_id: str, 216 assignment_id: str, 217 user_id: str, 218 **kwargs: typing.Any) -> typing.Union[lms.model.scores.AssignmentScore, None]: 219 """ 220 Fetch the score associated with the given assignment and user. 221 222 By default, this will just do a list and choose the relevant record. 223 Specific backends may override this if there are performance concerns. 224 """ 225 226 scores = self.courses_assignments_scores_list(course_id, assignment_id, **kwargs) 227 for score in scores: 228 if ((score.user is not None) and (score.user.id == user_id)): 229 return score 230 231 return None 232 233 def courses_assignments_scores_list(self, 234 course_id: str, 235 assignment_id: str, 236 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 237 """ 238 List the scores associated with the given assignment. 239 """ 240 241 raise NotImplementedError('courses_assignments_scores_list') 242 243 def courses_assignments_scores_resolve_and_list(self, 244 course_query: lms.model.courses.CourseQuery, 245 assignment_query: lms.model.assignments.AssignmentQuery, 246 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 247 """ 248 List the scores associated with the given assignment query. 249 In addition to resolving the assignment query, 250 users will also be resolved into their full version 251 (instead of the reduced version usually returned with scores). 252 """ 253 254 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 255 256 # Resolve the assignment query. 257 matched_assignments = self.courses_assignments_get(resolved_course_query, [assignment_query], **kwargs) 258 if (len(matched_assignments) == 0): 259 return [] 260 261 target_assignment = matched_assignments[0] 262 263 # List the scores. 264 scores = self.courses_assignments_scores_list(resolved_course_query.get_id(), target_assignment.id, **kwargs) 265 if (len(scores) == 0): 266 return [] 267 268 # Resolve the scores' queries. 269 270 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 271 users_map = {user.id: user for user in users} 272 273 for score in scores: 274 score.assignment = target_assignment.to_query() 275 276 if ((score.user is not None) and (score.user.id in users_map)): 277 score.user = users_map[score.user.id].to_query() 278 279 return sorted(scores) 280 281 def courses_assignments_scores_resolve_and_upload(self, 282 course_query: lms.model.courses.CourseQuery, 283 assignment_query: lms.model.assignments.AssignmentQuery, 284 scores: typing.Dict[lms.model.users.UserQuery, lms.model.scores.ScoreFragment], 285 **kwargs: typing.Any) -> int: 286 """ 287 Resolve queries and upload assignment scores (indexed by user query). 288 A None score (ScoreFragment.score) indicates that the score should be cleared. 289 Return the number of scores sent to the LMS. 290 """ 291 292 if (len(scores) == 0): 293 return 0 294 295 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 296 resolved_assignment_query = self.resolve_assignment_query(resolved_course_query.get_id(), assignment_query, **kwargs) 297 298 resolved_users = self.resolve_user_queries(resolved_course_query.get_id(), list(scores.keys()), warn_on_miss = True) 299 resolved_scores: typing.Dict[str, lms.model.scores.ScoreFragment] = {} 300 301 for (user, score) in scores.items(): 302 for resolved_user in resolved_users: 303 if (user.match(resolved_user)): 304 resolved_scores[resolved_user.get_id()] = score 305 continue 306 307 if (len(resolved_scores) == 0): 308 return 0 309 310 return self.courses_assignments_scores_upload( 311 resolved_course_query.get_id(), 312 resolved_assignment_query.get_id(), 313 resolved_scores, 314 **kwargs) 315 316 def courses_assignments_scores_upload(self, 317 course_id: str, 318 assignment_id: str, 319 scores: typing.Dict[str, lms.model.scores.ScoreFragment], 320 **kwargs: typing.Any) -> int: 321 """ 322 Upload assignment scores (indexed by user id). 323 A None score (ScoreFragment.score) indicates that the score should be cleared. 324 Return the number of scores sent to the LMS. 325 """ 326 327 raise NotImplementedError('courses_assignments_scores_upload') 328 329 def courses_gradebook_get(self, 330 course_query: lms.model.courses.CourseQuery, 331 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 332 user_queries: typing.Collection[lms.model.users.UserQuery], 333 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 334 """ 335 Get a gradebook with the specified users and assignments. 336 Specifying no users/assignments is the same as requesting all of them. 337 """ 338 339 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 340 341 resolved_assignment_queries = self.resolve_assignment_queries(resolved_course_query.get_id(), assignment_queries, empty_all = True, **kwargs) 342 assignment_ids = [query.get_id() for query in resolved_assignment_queries] 343 344 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, 345 empty_all = True, only_students = True, **kwargs) 346 user_ids = [query.get_id() for query in resolved_user_queries] 347 348 gradebook = self.courses_gradebook_fetch(resolved_course_query.get_id(), assignment_ids, user_ids, **kwargs) 349 350 # Resolve the gradebook's queries (so it can show names/emails instead of just IDs). 351 gradebook.update_queries(resolved_assignment_queries, resolved_user_queries) 352 353 return gradebook 354 355 def courses_gradebook_fetch(self, 356 course_id: str, 357 assignment_ids: typing.Collection[str], 358 user_ids: typing.Collection[str], 359 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 360 """ 361 Get a gradebook with the specified users and assignments. 362 If either the assignments or users is empty, an empty gradebook will be returned. 363 """ 364 365 raise NotImplementedError('courses_gradebook_fetch') 366 367 def courses_gradebook_list(self, 368 course_id: str, 369 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 370 """ 371 List the full gradebook associated with this course. 372 """ 373 374 return self.courses_gradebook_get(lms.model.courses.CourseQuery(id = course_id), [], [], **kwargs) 375 376 def courses_gradebook_resolve_and_list(self, 377 course_query: lms.model.courses.CourseQuery, 378 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 379 """ 380 List the full gradebook associated with this course. 381 """ 382 383 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 384 return self.courses_gradebook_list(resolved_course_query.get_id(), **kwargs) 385 386 def courses_gradebook_resolve_and_upload(self, 387 course_query: lms.model.courses.CourseQuery, 388 gradebook: lms.model.scores.Gradebook, 389 **kwargs: typing.Any) -> int: 390 """ 391 Resolve queries and upload a gradebook. 392 Missing scores in the gradebook are skipped, 393 a None score (ScoreFragment.score) indicates that the score should be cleared. 394 Return the number of scores sent to the LMS. 395 """ 396 397 if (len(gradebook) == 0): 398 return 0 399 400 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 401 402 assignments = self.courses_assignments_list(resolved_course_query.get_id(), **kwargs) 403 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 404 405 resolved_assignment_queries = [assignment.to_query() for assignment in assignments] 406 resolved_user_queries = [user.to_query() for user in users] 407 408 gradebook.update_queries(resolved_assignment_queries, resolved_user_queries) 409 410 return self.courses_gradebook_upload( 411 resolved_course_query.get_id(), 412 gradebook, 413 **kwargs) 414 415 def courses_gradebook_upload(self, 416 course_id: str, 417 gradebook: lms.model.scores.Gradebook, 418 **kwargs: typing.Any) -> int: 419 """ 420 Upload a gradebook. 421 All queries in the gradebook must be resolved (or at least have an ID). 422 Missing scores in the gradebook are skipped, 423 a None score (ScoreFragment.score) indicates that the score should be cleared. 424 Return the number of scores sent to the LMS. 425 """ 426 427 assignment_scores = gradebook.get_scores_by_assignment() 428 429 count = 0 430 for (assignment, user_scores) in assignment_scores.items(): 431 if (assignment.id is None): 432 raise ValueError(f"Assignment query for gradebook upload ({assignment}) does not have an ID.") 433 434 upload_scores = {} 435 for (user, score) in user_scores.items(): 436 if (user.id is None): 437 raise ValueError(f"User query for gradebook upload ({user}) does not have an ID.") 438 439 upload_scores[user.id] = score.to_fragment() 440 441 count += self.courses_assignments_scores_upload(course_id, assignment.id, upload_scores, **kwargs) 442 443 return count 444 445 def courses_groupsets_create(self, 446 course_id: str, 447 name: str, 448 **kwargs: typing.Any) -> lms.model.groupsets.GroupSet: 449 """ 450 Create a group set. 451 """ 452 453 raise NotImplementedError('courses_groupsets_create') 454 455 def courses_groupsets_resolve_and_create(self, 456 course_query: lms.model.courses.CourseQuery, 457 name: str, 458 **kwargs: typing.Any) -> lms.model.groupsets.GroupSet: 459 """ 460 Resolve references and create a group set. 461 """ 462 463 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 464 return self.courses_groupsets_create(resolved_course_query.get_id(), name, **kwargs) 465 466 def courses_groupsets_delete(self, 467 course_id: str, 468 groupset_id: str, 469 **kwargs: typing.Any) -> bool: 470 """ 471 Delete a group set. 472 """ 473 474 raise NotImplementedError('courses_groupsets_delete') 475 476 def courses_groupsets_resolve_and_delete(self, 477 course_query: lms.model.courses.CourseQuery, 478 groupset_query: lms.model.groupsets.GroupSetQuery, 479 **kwargs: typing.Any) -> bool: 480 """ 481 Resolve references and create a group set. 482 """ 483 484 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 485 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 486 return self.courses_groupsets_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 487 488 def courses_groupsets_get(self, 489 course_query: lms.model.courses.CourseQuery, 490 groupset_queries: typing.Collection[lms.model.groupsets.GroupSetQuery], 491 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 492 """ 493 Get the specified group sets associated with the given course. 494 """ 495 496 if (len(groupset_queries) == 0): 497 return [] 498 499 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 500 groupset_queries = sorted(groupset_queries) 501 groupsets = sorted(self.courses_groupsets_list(resolved_course_query.get_id(), **kwargs)) 502 503 matches = [] 504 for groupset in groupsets: 505 for query in groupset_queries: 506 if (query.match(groupset)): 507 matches.append(groupset) 508 break 509 510 return matches 511 512 def courses_groupsets_fetch(self, 513 course_id: str, 514 groupset_id: str, 515 **kwargs: typing.Any) -> typing.Union[lms.model.groupsets.GroupSet, None]: 516 """ 517 Fetch a single group set associated with the given course. 518 Return None if no matching group set is found. 519 520 By default, this will just do a list and choose the relevant record. 521 Specific backends may override this if there are performance concerns. 522 """ 523 524 groupsets = self.courses_groupsets_list(course_id, **kwargs) 525 for groupset in groupsets: 526 if (groupset.id == groupset_id): 527 return groupset 528 529 return None 530 531 def courses_groupsets_list(self, 532 course_id: str, 533 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 534 """ 535 List the group sets associated with the given course. 536 """ 537 538 raise NotImplementedError('courses_groupsets_list') 539 540 def courses_groupsets_resolve_and_list(self, 541 course_query: lms.model.courses.CourseQuery, 542 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 543 """ 544 List the group sets associated with the given course. 545 """ 546 547 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 548 return sorted(self.courses_groupsets_list(resolved_course_query.get_id(), **kwargs)) 549 550 def courses_groupsets_memberships_resolve_and_add(self, 551 course_query: lms.model.courses.CourseQuery, 552 groupset_query: lms.model.groupsets.GroupSetQuery, 553 memberships: typing.Collection[lms.model.groups.GroupMembership], 554 **kwargs: typing.Any) -> typing.Tuple[ 555 typing.List[lms.model.groups.Group], 556 typing.Dict[lms.model.groups.ResolvedGroupQuery, int] 557 ]: 558 """ 559 Resolve queries and add the specified users to the specified groups. 560 This may create groups. 561 562 Return: 563 - Created Groups 564 - Group Addition Counts 565 """ 566 567 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 568 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 569 570 found_group_memberships, missing_group_memberships, _ = self._resolve_group_memberships( 571 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 572 573 # Create missing groups. 574 created_groups = [] 575 for name in sorted(missing_group_memberships.keys()): 576 group = self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 577 created_groups.append(group) 578 579 # Merge in new group with existing structure. 580 query = group.to_query() 581 if (query not in found_group_memberships): 582 found_group_memberships[query] = [] 583 584 found_group_memberships[query] += missing_group_memberships[name] 585 586 # Add memberships. 587 counts = {} 588 for resolved_group_query in sorted(found_group_memberships.keys()): 589 resolved_user_queries = found_group_memberships[resolved_group_query] 590 591 count = self.courses_groups_memberships_resolve_and_add( 592 resolved_course_query, resolved_groupset_query, resolved_group_query, 593 resolved_user_queries, 594 **kwargs) 595 596 counts[resolved_group_query] = count 597 598 return (created_groups, counts) 599 600 def courses_groupsets_memberships_resolve_and_set(self, 601 course_query: lms.model.courses.CourseQuery, 602 groupset_query: lms.model.groupsets.GroupSetQuery, 603 memberships: typing.Collection[lms.model.groups.GroupMembership], 604 **kwargs: typing.Any) -> typing.Tuple[ 605 typing.List[lms.model.groups.Group], 606 typing.List[lms.model.groups.ResolvedGroupQuery], 607 typing.Dict[lms.model.groups.ResolvedGroupQuery, int], 608 typing.Dict[lms.model.groups.ResolvedGroupQuery, int], 609 ]: 610 """ 611 Resolve queries and set the specified group memberships. 612 This may create and delete groups. 613 614 Return: 615 - Created Groups 616 - Deleted Groups 617 - Group Addition Counts 618 - Group Subtraction Counts 619 """ 620 621 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 622 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 623 624 found_group_memberships, missing_group_memberships, unused_groups = self._resolve_group_memberships( 625 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 626 627 # Delete unused groups. 628 deleted_groups = [] 629 for group_query in sorted(unused_groups): 630 result = self.courses_groups_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query.get_id(), **kwargs) 631 if (result): 632 deleted_groups.append(group_query) 633 634 # Create missing groups. 635 created_groups = [] 636 for name in sorted(missing_group_memberships.keys()): 637 group = self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 638 created_groups.append(group) 639 640 # Merge in new group with existing structure. 641 query = group.to_query() 642 if (query not in found_group_memberships): 643 found_group_memberships[query] = [] 644 645 found_group_memberships[query] += missing_group_memberships[name] 646 647 # Set memberships. 648 add_counts = {} 649 sub_counts = {} 650 for resolved_group_query in sorted(found_group_memberships.keys()): 651 resolved_user_queries = found_group_memberships[resolved_group_query] 652 653 (add_count, sub_count, deleted) = self.courses_groups_memberships_resolve_and_set( 654 resolved_course_query, resolved_groupset_query, resolved_group_query, 655 resolved_user_queries, 656 delete_empty = True, 657 **kwargs) 658 659 if (deleted): 660 deleted_groups.append(resolved_group_query) 661 662 add_counts[resolved_group_query] = add_count 663 sub_counts[resolved_group_query] = sub_count 664 665 return (created_groups, deleted_groups, add_counts, sub_counts) 666 667 def courses_groupsets_memberships_resolve_and_subtract(self, 668 course_query: lms.model.courses.CourseQuery, 669 groupset_query: lms.model.groupsets.GroupSetQuery, 670 memberships: typing.Collection[lms.model.groups.GroupMembership], 671 **kwargs: typing.Any) -> typing.Dict[lms.model.groups.ResolvedGroupQuery, int]: 672 """ 673 Resolve queries and subtract the specified users to the specified groups. 674 This will not delete any groups. 675 676 Return: 677 - Group Subtraction Counts 678 """ 679 680 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 681 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 682 683 found_group_memberships, missing_group_memberships, _ = self._resolve_group_memberships( 684 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 685 686 # Warn about missing groups. 687 for name in sorted(missing_group_memberships.keys()): 688 _logger.warning("Group does not exist: '%s'.", name) 689 690 # Subtract memberships. 691 counts = {} 692 for resolved_group_query in sorted(found_group_memberships.keys()): 693 resolved_user_queries = found_group_memberships[resolved_group_query] 694 695 (count, _) = self.courses_groups_memberships_resolve_and_subtract( 696 resolved_course_query, resolved_groupset_query, resolved_group_query, 697 resolved_user_queries, 698 delete_empty = False, 699 **kwargs) 700 701 counts[resolved_group_query] = count 702 703 return counts 704 705 def courses_groupsets_memberships_list(self, 706 course_id: str, 707 groupset_id: str, 708 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 709 """ 710 List the membership of the group sets associated with the given course. 711 """ 712 713 raise NotImplementedError('courses_groupsets_memberships_list') 714 715 def courses_groupsets_memberships_resolve_and_list(self, 716 course_query: lms.model.courses.CourseQuery, 717 groupset_query: lms.model.groupsets.GroupSetQuery, 718 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 719 """ 720 List the membership of the group sets associated with the given course. 721 """ 722 723 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 724 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 725 726 memberships = self.courses_groupsets_memberships_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 727 728 # Resolve memberships. 729 730 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 731 groups = self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 732 733 users_map = {user.id: user.to_query() for user in users} 734 groups_map = {group.id: group.to_query() for group in groups} 735 736 for membership in memberships: 737 membership.update_queries(resolved_groupset_query, users = users_map, groups = groups_map) 738 739 return sorted(memberships) 740 741 def courses_groups_create(self, 742 course_id: str, 743 groupset_id: str, 744 name: str, 745 **kwargs: typing.Any) -> lms.model.groups.Group: 746 """ 747 Create a group. 748 """ 749 750 raise NotImplementedError('courses_groups_create') 751 752 def courses_groups_resolve_and_create(self, 753 course_query: lms.model.courses.CourseQuery, 754 groupset_query: lms.model.groupsets.GroupSetQuery, 755 name: str, 756 **kwargs: typing.Any) -> lms.model.groups.Group: 757 """ 758 Resolve references and create a group. 759 """ 760 761 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 762 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 763 return self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 764 765 def courses_groups_delete(self, 766 course_id: str, 767 groupset_id: str, 768 group_id: str, 769 **kwargs: typing.Any) -> bool: 770 """ 771 Delete a group. 772 """ 773 774 raise NotImplementedError('courses_groups_delete') 775 776 def courses_groups_resolve_and_delete(self, 777 course_query: lms.model.courses.CourseQuery, 778 groupset_query: lms.model.groupsets.GroupSetQuery, 779 group_query: lms.model.groups.GroupQuery, 780 **kwargs: typing.Any) -> bool: 781 """ 782 Resolve references and create a group. 783 """ 784 785 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 786 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 787 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 788 return self.courses_groups_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), resolved_group_query.get_id(), **kwargs) 789 790 def courses_groups_get(self, 791 course_query: lms.model.courses.CourseQuery, 792 groupset_query: lms.model.groupsets.GroupSetQuery, 793 group_queries: typing.Collection[lms.model.groups.GroupQuery], 794 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 795 """ 796 Get the specified groups associated with the given course. 797 """ 798 799 if (len(group_queries) == 0): 800 return [] 801 802 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 803 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 804 groups = self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 805 806 group_queries = sorted(group_queries) 807 groups = sorted(groups) 808 809 matches = [] 810 for group in groups: 811 for query in group_queries: 812 if (query.match(group)): 813 matches.append(group) 814 break 815 816 return matches 817 818 def courses_groups_fetch(self, 819 course_id: str, 820 groupset_id: str, 821 group_id: str, 822 **kwargs: typing.Any) -> typing.Union[lms.model.groups.Group, None]: 823 """ 824 Fetch a single group associated with the given course. 825 Return None if no matching group is found. 826 827 By default, this will just do a list and choose the relevant record. 828 Specific backends may override this if there are performance concerns. 829 """ 830 831 groups = self.courses_groups_list(course_id, groupset_id, **kwargs) 832 for group in groups: 833 if (group.id == group_id): 834 return group 835 836 return None 837 838 def courses_groups_list(self, 839 course_id: str, 840 groupset_id: str, 841 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 842 """ 843 List the groups associated with the given course. 844 """ 845 846 raise NotImplementedError('courses_groups_list') 847 848 def courses_groups_resolve_and_list(self, 849 course_query: lms.model.courses.CourseQuery, 850 groupset_query: lms.model.groupsets.GroupSetQuery, 851 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 852 """ 853 List the groups associated with the given course. 854 """ 855 856 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 857 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 858 return self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 859 860 def courses_groups_memberships_add(self, 861 course_id: str, 862 groupset_id: str, 863 group_id: str, 864 user_ids: typing.Collection[str], 865 **kwargs: typing.Any) -> int: 866 """ 867 Add the specified users to the group. 868 """ 869 870 raise NotImplementedError('courses_groups_memberships_add') 871 872 def courses_groups_memberships_resolve_and_add(self, 873 course_query: lms.model.courses.CourseQuery, 874 groupset_query: lms.model.groupsets.GroupSetQuery, 875 group_query: lms.model.groups.GroupQuery, 876 user_queries: typing.Collection[lms.model.users.UserQuery], 877 **kwargs: typing.Any) -> int: 878 """ 879 Resolve queries and add the specified users to the group. 880 """ 881 882 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 883 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 884 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 885 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 886 887 # Get users already in this group. 888 group_memberships = self.courses_groups_memberships_list( 889 resolved_course_query.get_id(), 890 resolved_groupset_query.get_id(), 891 resolved_group_query.get_id(), 892 **kwargs) 893 894 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 895 896 # Filter out users already in the group. 897 user_ids = [] 898 for query in sorted(resolved_user_queries): 899 if (query.get_id() in group_user_ids): 900 _logger.warning("User '%s' already in group '%s'.", query, resolved_group_query) 901 continue 902 903 user_ids.append(query.get_id()) 904 905 if (len(user_ids) == 0): 906 return 0 907 908 return self.courses_groups_memberships_add( 909 resolved_course_query.get_id(), 910 resolved_groupset_query.get_id(), 911 resolved_group_query.get_id(), 912 user_ids, 913 **kwargs) 914 915 def courses_groups_memberships_list(self, 916 course_id: str, 917 groupset_id: str, 918 group_id: str, 919 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 920 """ 921 List the membership of the group associated with the given group set. 922 """ 923 924 raise NotImplementedError('courses_groups_memberships_list') 925 926 def courses_groups_memberships_resolve_and_list(self, 927 course_query: lms.model.courses.CourseQuery, 928 groupset_query: lms.model.groupsets.GroupSetQuery, 929 group_query: lms.model.groups.GroupQuery, 930 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 931 """ 932 List the membership of the group associated with the given group set. 933 """ 934 935 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 936 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 937 938 groups = self.courses_groups_get(resolved_course_query, resolved_groupset_query, [group_query], **kwargs) 939 if (len(groups) == 0): 940 raise ValueError(f"Unable to find group: '{group_query}'.") 941 942 group = groups[0] 943 944 memberships = self.courses_groups_memberships_list( 945 resolved_course_query.get_id(), 946 resolved_groupset_query.get_id(), 947 group.id, 948 **kwargs) 949 950 # Resolve memberships. 951 952 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 953 users_map = {user.id: user.to_query() for user in users} 954 955 groups_map = {group.id: group.to_query()} 956 957 for membership in memberships: 958 membership.update_queries(resolved_groupset_query, users = users_map, groups = groups_map) 959 960 return sorted(memberships) 961 962 def courses_groups_memberships_resolve_and_set(self, 963 course_query: lms.model.courses.CourseQuery, 964 groupset_query: lms.model.groupsets.GroupSetQuery, 965 group_query: lms.model.groups.GroupQuery, 966 user_queries: typing.Collection[lms.model.users.UserQuery], 967 delete_empty: bool = False, 968 **kwargs: typing.Any) -> typing.Tuple[int, int, bool]: 969 """ 970 Resolve queries and set the specified users for the group. 971 This method can both add and subtract users from the group. 972 973 Returns: 974 - The count of users added. 975 - The count of users subtracted. 976 - If this group was deleted. 977 """ 978 979 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 980 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 981 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 982 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 983 984 # Get users already in this group. 985 group_memberships = self.courses_groups_memberships_list( 986 resolved_course_query.get_id(), 987 resolved_groupset_query.get_id(), 988 resolved_group_query.get_id(), 989 **kwargs) 990 991 group_user_queries = {membership.user for membership in group_memberships if membership.user is not None} 992 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 993 query_user_ids = {resolved_user_query.get_id() for resolved_user_query in resolved_user_queries} 994 995 # Collect users that need to be added. 996 add_user_ids = [] 997 for query_user_id in query_user_ids: 998 if (query_user_id not in group_user_ids): 999 add_user_ids.append(query_user_id) 1000 1001 # Collect users that need to be subtracted. 1002 sub_user_queries = [] 1003 for group_user_query in group_user_queries: 1004 if (group_user_query not in resolved_user_queries): 1005 sub_user_queries.append(group_user_query) 1006 1007 # Update the group. 1008 1009 add_count = 0 1010 if (len(add_user_ids) > 0): 1011 add_count = self.courses_groups_memberships_add( 1012 resolved_course_query.get_id(), 1013 resolved_groupset_query.get_id(), 1014 resolved_group_query.get_id(), 1015 add_user_ids, 1016 **kwargs) 1017 1018 sub_count = 0 1019 deleted = False 1020 if (len(sub_user_queries) > 0): 1021 sub_count, deleted = self.courses_groups_memberships_resolve_and_subtract( 1022 resolved_course_query, 1023 resolved_groupset_query, 1024 resolved_group_query, 1025 sub_user_queries, 1026 delete_empty = delete_empty, 1027 **kwargs) 1028 1029 return add_count, sub_count, deleted 1030 1031 def courses_groups_memberships_subtract(self, 1032 course_id: str, 1033 groupset_id: str, 1034 group_id: str, 1035 user_ids: typing.Collection[str], 1036 **kwargs: typing.Any) -> int: 1037 """ 1038 Subtract the specified users from the group. 1039 """ 1040 1041 raise NotImplementedError('courses_groups_memberships_subtract') 1042 1043 def courses_groups_memberships_resolve_and_subtract(self, 1044 course_query: lms.model.courses.CourseQuery, 1045 groupset_query: lms.model.groupsets.GroupSetQuery, 1046 group_query: lms.model.groups.GroupQuery, 1047 user_queries: typing.Collection[lms.model.users.UserQuery], 1048 delete_empty: bool = False, 1049 **kwargs: typing.Any) -> typing.Tuple[int, bool]: 1050 """ 1051 Resolve queries and subtract the specified users from the group. 1052 Return: 1053 - The number of users deleted. 1054 - If this group was deleted. 1055 """ 1056 1057 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1058 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 1059 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 1060 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 1061 1062 # Get users already in this group. 1063 group_memberships = self.courses_groups_memberships_list( 1064 resolved_course_query.get_id(), 1065 resolved_groupset_query.get_id(), 1066 resolved_group_query.get_id(), 1067 **kwargs) 1068 1069 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 1070 1071 # Filter out users not in the group. 1072 user_ids = [] 1073 for query in resolved_user_queries: 1074 if (query.get_id() not in group_user_ids): 1075 _logger.warning("User '%s' is not in group '%s'.", query, resolved_group_query) 1076 continue 1077 1078 user_ids.append(query.get_id()) 1079 1080 if (delete_empty and len(group_memberships) == 0): 1081 deleted = self.courses_groups_delete( 1082 resolved_course_query.get_id(), 1083 resolved_groupset_query.get_id(), 1084 resolved_group_query.get_id(), 1085 **kwargs) 1086 return 0, deleted 1087 1088 if (len(user_ids) == 0): 1089 return 0, False 1090 1091 count = self.courses_groups_memberships_subtract( 1092 resolved_course_query.get_id(), 1093 resolved_groupset_query.get_id(), 1094 resolved_group_query.get_id(), 1095 user_ids, 1096 **kwargs) 1097 1098 deleted = False 1099 if (delete_empty and (count == len(group_memberships))): 1100 deleted = self.courses_groups_delete( 1101 resolved_course_query.get_id(), 1102 resolved_groupset_query.get_id(), 1103 resolved_group_query.get_id(), 1104 **kwargs) 1105 1106 return count, deleted 1107 1108 def courses_syllabus_fetch(self, 1109 course_id: str, 1110 **kwargs: typing.Any) -> typing.Union[str, None]: 1111 """ 1112 Get the syllabus for a course, or None if no syllabus exists. 1113 """ 1114 1115 raise NotImplementedError('courses_syllabus_fetch') 1116 1117 def courses_syllabus_get(self, 1118 course_query: lms.model.courses.CourseQuery, 1119 **kwargs: typing.Any) -> typing.Union[str, None]: 1120 """ 1121 Get the syllabus for a course query, or None if no syllabus exists. 1122 """ 1123 1124 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1125 1126 return self.courses_syllabus_fetch(resolved_course_query.get_id(), **kwargs) 1127 1128 def courses_users_get(self, 1129 course_query: lms.model.courses.CourseQuery, 1130 user_queries: typing.Collection[lms.model.users.UserQuery], 1131 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1132 """ 1133 Get the specified users associated with the given course. 1134 """ 1135 1136 if (len(user_queries) == 0): 1137 return [] 1138 1139 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1140 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 1141 1142 user_queries = sorted(user_queries) 1143 users = sorted(users) 1144 1145 matches = [] 1146 for user in users: 1147 for query in user_queries: 1148 if (query.match(user)): 1149 matches.append(user) 1150 break 1151 1152 return matches 1153 1154 def courses_users_fetch(self, 1155 course_id: str, 1156 user_id: str, 1157 **kwargs: typing.Any) -> typing.Union[lms.model.users.CourseUser, None]: 1158 """ 1159 Fetch a single user associated with the given course. 1160 Return None if no matching user is found. 1161 1162 By default, this will just do a list and choose the relevant record. 1163 Specific backends may override this if there are performance concerns. 1164 """ 1165 1166 users = self.courses_users_list(course_id, **kwargs) 1167 for user in sorted(users): 1168 if (user.id == user_id): 1169 return user 1170 1171 return None 1172 1173 def courses_users_list(self, 1174 course_id: str, 1175 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1176 """ 1177 List the users associated with the given course. 1178 """ 1179 1180 raise NotImplementedError('courses_users_list') 1181 1182 def courses_users_resolve_and_list(self, 1183 course_query: lms.model.courses.CourseQuery, 1184 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1185 """ 1186 List the users associated with the given course. 1187 """ 1188 1189 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1190 return list(sorted(self.courses_users_list(resolved_course_query.get_id(), **kwargs))) 1191 1192 def courses_users_scores_get(self, 1193 course_query: lms.model.courses.CourseQuery, 1194 user_query: lms.model.users.UserQuery, 1195 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 1196 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1197 """ 1198 Get the scores associated with the given user query and assignment queries. 1199 """ 1200 1201 if (len(assignment_queries) == 0): 1202 return [] 1203 1204 scores = self.courses_users_scores_resolve_and_list(course_query, user_query, **kwargs) 1205 1206 scores = sorted(scores) 1207 assignment_queries = sorted(assignment_queries) 1208 1209 matches = [] 1210 for score in scores: 1211 for assignment_query in assignment_queries: 1212 if (assignment_query.match(score.assignment)): 1213 matches.append(score) 1214 1215 return matches 1216 1217 def courses_users_scores_fetch(self, 1218 course_id: str, 1219 user_id: str, 1220 assignment_id: str, 1221 **kwargs: typing.Any) -> typing.Union[lms.model.scores.AssignmentScore, None]: 1222 """ 1223 Fetch the score associated with the given user and assignment. 1224 1225 By default, this will just do a list and choose the relevant record. 1226 Specific backends may override this if there are performance concerns. 1227 """ 1228 1229 # The default implementation is the same as courses_assignments_scores_fetch(). 1230 return self.courses_assignments_scores_fetch(course_id, assignment_id, user_id, **kwargs) 1231 1232 def courses_users_scores_list(self, 1233 course_id: str, 1234 user_id: str, 1235 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1236 """ 1237 List the scores associated with the given user. 1238 """ 1239 1240 raise NotImplementedError('courses_users_scores_list') 1241 1242 def courses_users_scores_resolve_and_list(self, 1243 course_query: lms.model.courses.CourseQuery, 1244 user_query: lms.model.users.UserQuery, 1245 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1246 """ 1247 List the scores associated with the given user query. 1248 In addition to resolving the user query, 1249 assignments will also be resolved into their full version 1250 (instead of the reduced version usually returned with scores). 1251 """ 1252 1253 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1254 1255 # Resolve the user query. 1256 matched_users = self.courses_users_get(resolved_course_query, [user_query], **kwargs) 1257 if (len(matched_users) == 0): 1258 return [] 1259 1260 target_user = matched_users[0] 1261 1262 # List the scores. 1263 scores = self.courses_users_scores_list(resolved_course_query.get_id(), target_user.id, **kwargs) 1264 if (len(scores) == 0): 1265 return [] 1266 1267 # Resolve the scores' queries. 1268 1269 assignments = self.courses_assignments_list(resolved_course_query.get_id(), **kwargs) 1270 assignments_map = {assignment.id: assignment for assignment in assignments} 1271 1272 for score in scores: 1273 score.user = target_user.to_query() 1274 1275 if ((score.assignment is not None) and (score.assignment.id in assignments_map)): 1276 score.assignment = assignments_map[score.assignment.id].to_query() 1277 1278 return sorted(scores) 1279 1280 # Utility Methods 1281 1282 def parse_assignment_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.assignments.AssignmentQuery, None]: 1283 """ 1284 Attempt to parse an assignment query from a string. 1285 The there is no query, return a None. 1286 If the query is malformed, raise an exception. 1287 1288 By default, this method assumes that LMS IDs are ints. 1289 Child backends may override this to implement their specific behavior. 1290 """ 1291 1292 return lms.model.query.parse_int_query(lms.model.assignments.AssignmentQuery, text, check_email = False) 1293 1294 def parse_assignment_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.assignments.AssignmentQuery]: 1295 """ Parse a list of assignment queries. """ 1296 1297 queries = [] 1298 for text in texts: 1299 query = self.parse_assignment_query(text) 1300 if (query is not None): 1301 queries.append(query) 1302 1303 return queries 1304 1305 def parse_course_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.courses.CourseQuery, None]: 1306 """ 1307 Attempt to parse a course query from a string. 1308 The there is no query, return a None. 1309 If the query is malformed, raise an exception. 1310 1311 By default, this method assumes that LMS IDs are ints. 1312 Child backends may override this to implement their specific behavior. 1313 """ 1314 1315 return lms.model.query.parse_int_query(lms.model.courses.CourseQuery, text, check_email = False) 1316 1317 def parse_course_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.courses.CourseQuery]: 1318 """ Parse a list of course queries. """ 1319 1320 queries = [] 1321 for text in texts: 1322 query = self.parse_course_query(text) 1323 if (query is not None): 1324 queries.append(query) 1325 1326 return queries 1327 1328 def parse_groupset_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.groupsets.GroupSetQuery, None]: 1329 """ 1330 Attempt to parse a group set query from a string. 1331 The there is no query, return a None. 1332 If the query is malformed, raise an exception. 1333 1334 By default, this method assumes that LMS IDs are ints. 1335 Child backends may override this to implement their specific behavior. 1336 """ 1337 1338 return lms.model.query.parse_int_query(lms.model.groupsets.GroupSetQuery, text, check_email = False) 1339 1340 def parse_groupset_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.groupsets.GroupSetQuery]: 1341 """ Parse a list of group set queries. """ 1342 1343 queries = [] 1344 for text in texts: 1345 query = self.parse_groupset_query(text) 1346 if (query is not None): 1347 queries.append(query) 1348 1349 return queries 1350 1351 def parse_group_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.groups.GroupQuery, None]: 1352 """ 1353 Attempt to parse a group query from a string. 1354 The there is no query, return a None. 1355 If the query is malformed, raise an exception. 1356 1357 By default, this method assumes that LMS IDs are ints. 1358 Child backends may override this to implement their specific behavior. 1359 """ 1360 1361 return lms.model.query.parse_int_query(lms.model.groups.GroupQuery, text, check_email = False) 1362 1363 def parse_group_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.groups.GroupQuery]: 1364 """ Parse a list of group queries. """ 1365 1366 queries = [] 1367 for text in texts: 1368 query = self.parse_group_query(text) 1369 if (query is not None): 1370 queries.append(query) 1371 1372 return queries 1373 1374 def parse_user_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.users.UserQuery, None]: 1375 """ 1376 Attempt to parse a user query from a string. 1377 The there is no query, return a None. 1378 If the query is malformed, raise an exception. 1379 1380 By default, this method assumes that LMS IDs are ints. 1381 Child backends may override this to implement their specific behavior. 1382 """ 1383 1384 return lms.model.query.parse_int_query(lms.model.users.UserQuery, text, check_email = True) 1385 1386 def parse_user_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.users.UserQuery]: 1387 """ Parse a list of user queries. """ 1388 1389 queries = [] 1390 for text in texts: 1391 query = self.parse_user_query(text) 1392 if (query is not None): 1393 queries.append(query) 1394 1395 return queries 1396 1397 def resolve_assignment_query(self, 1398 course_id: str, 1399 assignment_query: lms.model.assignments.AssignmentQuery, 1400 **kwargs: typing.Any) -> lms.model.assignments.ResolvedAssignmentQuery: 1401 """ Resolve the assignment query or raise an exception. """ 1402 1403 # Shortcut already resolved queries. 1404 if (isinstance(assignment_query, lms.model.assignments.ResolvedAssignmentQuery)): 1405 return assignment_query 1406 1407 results = self.resolve_assignment_queries(course_id, [assignment_query], **kwargs) 1408 if (len(results) == 0): 1409 raise ValueError(f"Could not resolve assignment query: '{assignment_query}'.") 1410 1411 return results[0] 1412 1413 def resolve_assignment_queries(self, 1414 course_id: str, 1415 queries: typing.Collection[lms.model.assignments.AssignmentQuery], 1416 **kwargs: typing.Any) -> typing.List[lms.model.assignments.ResolvedAssignmentQuery]: 1417 """ 1418 Resolve a list of assignment queries into a list of resolved assignment queries. 1419 See _resolve_queries(). 1420 """ 1421 1422 results = self._resolve_queries( 1423 queries, 1424 'assignment', 1425 self.courses_assignments_list(course_id, **kwargs), 1426 lms.model.assignments.ResolvedAssignmentQuery, 1427 **kwargs) 1428 1429 return typing.cast(typing.List[lms.model.assignments.ResolvedAssignmentQuery], results) 1430 1431 def resolve_course_query(self, 1432 query: lms.model.courses.CourseQuery, 1433 **kwargs: typing.Any) -> lms.model.courses.ResolvedCourseQuery: 1434 """ Resolve the course query or raise an exception. """ 1435 1436 # Shortcut already resolved queries. 1437 if (isinstance(query, lms.model.courses.ResolvedCourseQuery)): 1438 return query 1439 1440 results = self.resolve_course_queries([query], **kwargs) 1441 if (len(results) == 0): 1442 raise ValueError(f"Could not resolve course query: '{query}'.") 1443 1444 return results[0] 1445 1446 def resolve_course_queries(self, 1447 queries: typing.Collection[lms.model.courses.CourseQuery], 1448 **kwargs: typing.Any) -> typing.List[lms.model.courses.ResolvedCourseQuery]: 1449 """ 1450 Resolve a list of course queries into a list of resolved course queries. 1451 See _resolve_queries(). 1452 """ 1453 1454 results = self._resolve_queries( 1455 queries, 1456 'course', 1457 self.courses_list(**kwargs), 1458 lms.model.courses.ResolvedCourseQuery, 1459 **kwargs) 1460 1461 return typing.cast(typing.List[lms.model.courses.ResolvedCourseQuery], results) 1462 1463 def resolve_group_queries(self, 1464 course_id: str, 1465 groupset_id: str, 1466 queries: typing.Collection[lms.model.groups.GroupQuery], 1467 **kwargs: typing.Any) -> typing.List[lms.model.groups.ResolvedGroupQuery]: 1468 """ 1469 Resolve a list of group queries into a list of resolved group queries. 1470 See _resolve_queries(). 1471 """ 1472 1473 results = self._resolve_queries( 1474 queries, 1475 'group', 1476 self.courses_groups_list(course_id, groupset_id, **kwargs), 1477 lms.model.groups.ResolvedGroupQuery, 1478 **kwargs) 1479 1480 return typing.cast(typing.List[lms.model.groups.ResolvedGroupQuery], results) 1481 1482 def resolve_group_query(self, 1483 course_id: str, 1484 groupset_id: str, 1485 query: lms.model.groups.GroupQuery, 1486 **kwargs: typing.Any) -> lms.model.groups.ResolvedGroupQuery: 1487 """ Resolve the group query or raise an exception. """ 1488 1489 # Shortcut already resolved queries. 1490 if (isinstance(query, lms.model.groups.ResolvedGroupQuery)): 1491 return query 1492 1493 results = self.resolve_group_queries(course_id, groupset_id, [query], **kwargs) 1494 if (len(results) == 0): 1495 raise ValueError(f"Could not resolve group query: '{query}'.") 1496 1497 return results[0] 1498 1499 def resolve_groupset_queries(self, 1500 course_id: str, 1501 queries: typing.Collection[lms.model.groupsets.GroupSetQuery], 1502 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.ResolvedGroupSetQuery]: 1503 """ 1504 Resolve a list of group set queries into a list of resolved group set queries. 1505 See _resolve_queries(). 1506 """ 1507 1508 results = self._resolve_queries( 1509 queries, 1510 'group set', 1511 self.courses_groupsets_list(course_id, **kwargs), 1512 lms.model.groupsets.ResolvedGroupSetQuery, 1513 **kwargs) 1514 1515 return typing.cast(typing.List[lms.model.groupsets.ResolvedGroupSetQuery], results) 1516 1517 def resolve_groupset_query(self, 1518 course_id: str, 1519 groupset_query: lms.model.groupsets.GroupSetQuery, 1520 **kwargs: typing.Any) -> lms.model.groupsets.ResolvedGroupSetQuery: 1521 """ Resolve the group set query or raise an exception. """ 1522 1523 # Shortcut already resolved queries. 1524 if (isinstance(groupset_query, lms.model.groupsets.ResolvedGroupSetQuery)): 1525 return groupset_query 1526 1527 results = self.resolve_groupset_queries(course_id, [groupset_query], **kwargs) 1528 if (len(results) == 0): 1529 raise ValueError(f"Could not resolve group set query: '{groupset_query}'.") 1530 1531 return results[0] 1532 1533 def resolve_user_queries(self, 1534 course_id: str, 1535 queries: typing.Collection[lms.model.users.UserQuery], 1536 only_students: bool = False, 1537 **kwargs: typing.Any) -> typing.List[lms.model.users.ResolvedUserQuery]: 1538 """ 1539 Resolve a list of user queries into a list of resolved user queries. 1540 See _resolve_queries(). 1541 """ 1542 1543 filter_func = None 1544 if (only_students): 1545 filter_func = lambda user: user.is_student() # pylint: disable=unnecessary-lambda-assignment 1546 1547 results = self._resolve_queries( 1548 queries, 1549 'user', 1550 self.courses_users_list(course_id, **kwargs), 1551 lms.model.users.ResolvedUserQuery, 1552 filter_func = filter_func, 1553 **kwargs) 1554 1555 return typing.cast(typing.List[lms.model.users.ResolvedUserQuery], results) 1556 1557 def _resolve_queries(self, 1558 queries: typing.Collection[lms.model.query.BaseQuery], 1559 label: str, 1560 items: typing.Collection, 1561 resolved_query_class: typing.Type, 1562 empty_all: bool = False, 1563 warn_on_miss: bool = False, 1564 filter_func: typing.Union[typing.Callable, None] = None, 1565 **kwargs: typing.Any) -> typing.List[lms.model.query.ResolvedBaseQuery]: 1566 """ 1567 Resolve a list of queries. 1568 The returned list may be shorter than the list of queries (if input queries are not matched). 1569 The queries will be deduplicated and sorted. 1570 1571 If |empty_all| is true and no queries are specified, then all items will be returned. 1572 1573 If |filter_func| is passed, then that function will be called with each raw item, 1574 and ones that return true will be kept. 1575 """ 1576 1577 if (filter_func is not None): 1578 items = list(filter(filter_func, items)) 1579 1580 if (empty_all and (len(queries) == 0)): 1581 return list(sorted({resolved_query_class(item) for item in items})) 1582 1583 matched_queries = [] # type: ignore[var-annotated] 1584 for query in queries: 1585 match = False 1586 for item in items: 1587 if (query.match(item)): 1588 matched_query = resolved_query_class(item) 1589 1590 if (match): 1591 raise ValueError( 1592 f"Ambiguous {label} query ('{query}')" 1593 f" matches multiple {label}s ['{matched_queries[-1]}', '{matched_query}'].") 1594 1595 matched_queries.append(matched_query) 1596 match = True 1597 1598 if ((not match) and warn_on_miss): 1599 _logger.warning("Could not resolve %s query '%s'.", label, query) 1600 1601 return list(sorted(set(matched_queries))) 1602 1603 def _resolve_group_memberships(self, 1604 course_id: str, 1605 groupset_id: str, 1606 memberships: typing.Collection[lms.model.groups.GroupMembership], 1607 **kwargs: typing.Any) -> typing.Tuple[ 1608 typing.Dict[lms.model.groups.ResolvedGroupQuery, typing.List[lms.model.users.ResolvedUserQuery]], 1609 typing.Dict[str, typing.List[lms.model.users.ResolvedUserQuery]], 1610 typing.List[lms.model.groups.ResolvedGroupQuery]]: 1611 """ 1612 Resolve a list of group memberships. 1613 This method will resolved each query and split up the memberships by the appropriate group. 1614 If a group does not exist, the memberships will be split by apparent group name. 1615 1616 Returns: 1617 - Memberships in Found Groups (keyed by resolved group query) 1618 - Memberships in Missing Groups (keyed by apparent group name) 1619 - Groups not involved in any of the returned memberships. 1620 1621 The returned dicts will be the found groups (keyed by resolved query) and then the missing groups (keyed by apparent group name). 1622 """ 1623 1624 found_group_memberships: typing.Dict[lms.model.groups.ResolvedGroupQuery, typing.List[lms.model.users.ResolvedUserQuery]] = {} 1625 missing_group_memberships: typing.Dict[str, typing.List[lms.model.users.ResolvedUserQuery]] = {} 1626 1627 users = self.courses_users_list(course_id, **kwargs) 1628 resolved_user_queries = [user.to_query() for user in sorted(users)] 1629 1630 groups = self.courses_groups_list(course_id, groupset_id, **kwargs) 1631 resolved_group_queries = [group.to_query() for group in sorted(groups)] 1632 1633 for (i, membership) in enumerate(memberships): 1634 # Resolve user. 1635 1636 resolved_user_query = None 1637 for possible_user_query in resolved_user_queries: 1638 if (membership.user.match(possible_user_query)): 1639 resolved_user_query = possible_user_query 1640 break 1641 1642 if (resolved_user_query is None): 1643 _logger.warning("Could not resolve user '%s' for membership entry at index %d.", membership.user, i) 1644 continue 1645 1646 # Resolve group. 1647 1648 resolved_group_query = None 1649 for possible_group_query in resolved_group_queries: 1650 if (membership.group.match(possible_group_query)): 1651 resolved_group_query = possible_group_query 1652 break 1653 1654 # Add to the correct collection. 1655 1656 if (resolved_group_query is None): 1657 if ((membership.group.name is None) or (len(membership.group.name) == 0)): 1658 _logger.warning(("Membership entry at index %d has a group with no name." 1659 + " Ensure that non-existent groups all have names."), i) 1660 continue 1661 1662 if (membership.group.name not in missing_group_memberships): 1663 missing_group_memberships[membership.group.name] = [] 1664 1665 missing_group_memberships[membership.group.name].append(resolved_user_query) 1666 else: 1667 if (resolved_group_query not in found_group_memberships): 1668 found_group_memberships[resolved_group_query] = [] 1669 1670 found_group_memberships[resolved_group_query].append(resolved_user_query) 1671 1672 unused_groups = sorted(list(set(resolved_group_queries) - set(found_group_memberships.keys()))) 1673 1674 return (found_group_memberships, missing_group_memberships, unused_groups)
18class APIBackend(): 19 """ 20 API backends provide a unified interface to an LMS. 21 22 Note that instead of using an abstract class, 23 methods will raise a NotImplementedError by default. 24 This will allow child backends to fill in as much functionality as they can, 25 while still leaving gaps where they are incomplete or impossible. 26 """ 27 28 _testing_override: typing.Union[bool, None] = None 29 """ A top-level override to control testing status. """ 30 31 def __init__(self, 32 server: str, 33 backend_type: str, 34 testing: typing.Union[bool, str] = False, 35 **kwargs: typing.Any) -> None: 36 self.server: str = server 37 """ The server this backend will connect to. """ 38 39 self.backend_type: str = backend_type 40 """ 41 The type for this backend. 42 Should be set by the child class. 43 """ 44 45 parsed_testing = edq.util.parse.boolean(testing) 46 if (APIBackend._testing_override is not None): 47 parsed_testing = APIBackend._testing_override 48 49 self.testing: bool = parsed_testing 50 """ True if the backend is being used for a test. """ 51 52 # Core Methods 53 54 def is_testing(self) -> bool: 55 """ Check if this backend is in testing mode. """ 56 57 return self.testing 58 59 def get_standard_headers(self) -> typing.Dict[str, str]: 60 """ 61 Get standard headers for this backend. 62 Children should take care to set the write header when performing a write operation. 63 """ 64 65 return { 66 lms.model.constants.HEADER_KEY_BACKEND: self.backend_type, 67 lms.model.constants.HEADER_KEY_WRITE: 'false', 68 } 69 70 def not_found(self, operation: str, identifiers: typing.Dict[str, typing.Any]) -> None: 71 """ 72 Called when the backend was unable to find some object. 73 This will only be called when a requested object is not found, 74 e.g., a user requested by ID is not found. 75 This is not called when a list naturally returns zero results, 76 or when a query does not match any items. 77 """ 78 79 _logger.warning("Object not found during operation: '%s'. Identifiers: %s.", operation, identifiers) 80 81 # API Methods 82 83 def courses_get(self, 84 course_queries: typing.Collection[lms.model.courses.CourseQuery], 85 **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]: 86 """ 87 Get the specified courses associated with the given course. 88 """ 89 90 if (len(course_queries) == 0): 91 return [] 92 93 courses = self.courses_list(**kwargs) 94 95 matches = [] 96 for course in sorted(courses): 97 for query in course_queries: 98 if (query.match(course)): 99 matches.append(course) 100 break 101 102 return sorted(matches) 103 104 def courses_fetch(self, 105 course_id: str, 106 **kwargs: typing.Any) -> typing.Union[lms.model.courses.Course, None]: 107 """ 108 Fetch a single course associated with the context user. 109 Return None if no matching course is found. 110 111 By default, this will just do a list and choose the relevant record. 112 Specific backends may override this if there are performance concerns. 113 """ 114 115 courses = self.courses_list(**kwargs) 116 for course in courses: 117 if (course.id == course_id): 118 return course 119 120 return None 121 122 def courses_list(self, 123 **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]: 124 """ 125 List the courses associated with the context user. 126 """ 127 128 raise NotImplementedError('courses_list') 129 130 def courses_assignments_get(self, 131 course_query: lms.model.courses.CourseQuery, 132 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 133 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 134 """ 135 Get the specified assignments associated with the given course. 136 """ 137 138 if (len(assignment_queries) == 0): 139 return [] 140 141 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 142 143 assignments = sorted(self.courses_assignments_list(resolved_course_query.get_id(), **kwargs)) 144 assignment_queries = sorted(assignment_queries) 145 146 matches = [] 147 for assignment in assignments: 148 for query in assignment_queries: 149 if (query.match(assignment)): 150 matches.append(assignment) 151 break 152 153 return matches 154 155 def courses_assignments_fetch(self, 156 course_id: str, 157 assignment_id: str, 158 **kwargs: typing.Any) -> typing.Union[lms.model.assignments.Assignment, None]: 159 """ 160 Fetch a single assignment associated with the given course. 161 Return None if no matching assignment is found. 162 163 By default, this will just do a list and choose the relevant record. 164 Specific backends may override this if there are performance concerns. 165 """ 166 167 assignments = self.courses_assignments_list(course_id, **kwargs) 168 for assignment in sorted(assignments): 169 if (assignment.id == assignment_id): 170 return assignment 171 172 return None 173 174 def courses_assignments_list(self, 175 course_id: str, 176 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 177 """ 178 List the assignments associated with the given course. 179 """ 180 181 raise NotImplementedError('courses_assignments_list') 182 183 def courses_assignments_resolve_and_list(self, 184 course_query: lms.model.courses.CourseQuery, 185 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 186 """ 187 List the assignments associated with the given course. 188 """ 189 190 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 191 return sorted(self.courses_assignments_list(resolved_course_query.get_id(), **kwargs)) 192 193 def courses_assignments_scores_get(self, 194 course_query: lms.model.courses.CourseQuery, 195 assignment_query: lms.model.assignments.AssignmentQuery, 196 user_queries: typing.Collection[lms.model.users.UserQuery], 197 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 198 """ 199 Get the scores associated with the given assignment query and user queries. 200 """ 201 202 if (len(user_queries) == 0): 203 return [] 204 205 scores = self.courses_assignments_scores_resolve_and_list(course_query, assignment_query, **kwargs) 206 207 matches = [] 208 for score in scores: 209 for user_query in user_queries: 210 if (user_query.match(score.user)): 211 matches.append(score) 212 213 return sorted(matches) 214 215 def courses_assignments_scores_fetch(self, 216 course_id: str, 217 assignment_id: str, 218 user_id: str, 219 **kwargs: typing.Any) -> typing.Union[lms.model.scores.AssignmentScore, None]: 220 """ 221 Fetch the score associated with the given assignment and user. 222 223 By default, this will just do a list and choose the relevant record. 224 Specific backends may override this if there are performance concerns. 225 """ 226 227 scores = self.courses_assignments_scores_list(course_id, assignment_id, **kwargs) 228 for score in scores: 229 if ((score.user is not None) and (score.user.id == user_id)): 230 return score 231 232 return None 233 234 def courses_assignments_scores_list(self, 235 course_id: str, 236 assignment_id: str, 237 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 238 """ 239 List the scores associated with the given assignment. 240 """ 241 242 raise NotImplementedError('courses_assignments_scores_list') 243 244 def courses_assignments_scores_resolve_and_list(self, 245 course_query: lms.model.courses.CourseQuery, 246 assignment_query: lms.model.assignments.AssignmentQuery, 247 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 248 """ 249 List the scores associated with the given assignment query. 250 In addition to resolving the assignment query, 251 users will also be resolved into their full version 252 (instead of the reduced version usually returned with scores). 253 """ 254 255 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 256 257 # Resolve the assignment query. 258 matched_assignments = self.courses_assignments_get(resolved_course_query, [assignment_query], **kwargs) 259 if (len(matched_assignments) == 0): 260 return [] 261 262 target_assignment = matched_assignments[0] 263 264 # List the scores. 265 scores = self.courses_assignments_scores_list(resolved_course_query.get_id(), target_assignment.id, **kwargs) 266 if (len(scores) == 0): 267 return [] 268 269 # Resolve the scores' queries. 270 271 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 272 users_map = {user.id: user for user in users} 273 274 for score in scores: 275 score.assignment = target_assignment.to_query() 276 277 if ((score.user is not None) and (score.user.id in users_map)): 278 score.user = users_map[score.user.id].to_query() 279 280 return sorted(scores) 281 282 def courses_assignments_scores_resolve_and_upload(self, 283 course_query: lms.model.courses.CourseQuery, 284 assignment_query: lms.model.assignments.AssignmentQuery, 285 scores: typing.Dict[lms.model.users.UserQuery, lms.model.scores.ScoreFragment], 286 **kwargs: typing.Any) -> int: 287 """ 288 Resolve queries and upload assignment scores (indexed by user query). 289 A None score (ScoreFragment.score) indicates that the score should be cleared. 290 Return the number of scores sent to the LMS. 291 """ 292 293 if (len(scores) == 0): 294 return 0 295 296 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 297 resolved_assignment_query = self.resolve_assignment_query(resolved_course_query.get_id(), assignment_query, **kwargs) 298 299 resolved_users = self.resolve_user_queries(resolved_course_query.get_id(), list(scores.keys()), warn_on_miss = True) 300 resolved_scores: typing.Dict[str, lms.model.scores.ScoreFragment] = {} 301 302 for (user, score) in scores.items(): 303 for resolved_user in resolved_users: 304 if (user.match(resolved_user)): 305 resolved_scores[resolved_user.get_id()] = score 306 continue 307 308 if (len(resolved_scores) == 0): 309 return 0 310 311 return self.courses_assignments_scores_upload( 312 resolved_course_query.get_id(), 313 resolved_assignment_query.get_id(), 314 resolved_scores, 315 **kwargs) 316 317 def courses_assignments_scores_upload(self, 318 course_id: str, 319 assignment_id: str, 320 scores: typing.Dict[str, lms.model.scores.ScoreFragment], 321 **kwargs: typing.Any) -> int: 322 """ 323 Upload assignment scores (indexed by user id). 324 A None score (ScoreFragment.score) indicates that the score should be cleared. 325 Return the number of scores sent to the LMS. 326 """ 327 328 raise NotImplementedError('courses_assignments_scores_upload') 329 330 def courses_gradebook_get(self, 331 course_query: lms.model.courses.CourseQuery, 332 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 333 user_queries: typing.Collection[lms.model.users.UserQuery], 334 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 335 """ 336 Get a gradebook with the specified users and assignments. 337 Specifying no users/assignments is the same as requesting all of them. 338 """ 339 340 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 341 342 resolved_assignment_queries = self.resolve_assignment_queries(resolved_course_query.get_id(), assignment_queries, empty_all = True, **kwargs) 343 assignment_ids = [query.get_id() for query in resolved_assignment_queries] 344 345 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, 346 empty_all = True, only_students = True, **kwargs) 347 user_ids = [query.get_id() for query in resolved_user_queries] 348 349 gradebook = self.courses_gradebook_fetch(resolved_course_query.get_id(), assignment_ids, user_ids, **kwargs) 350 351 # Resolve the gradebook's queries (so it can show names/emails instead of just IDs). 352 gradebook.update_queries(resolved_assignment_queries, resolved_user_queries) 353 354 return gradebook 355 356 def courses_gradebook_fetch(self, 357 course_id: str, 358 assignment_ids: typing.Collection[str], 359 user_ids: typing.Collection[str], 360 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 361 """ 362 Get a gradebook with the specified users and assignments. 363 If either the assignments or users is empty, an empty gradebook will be returned. 364 """ 365 366 raise NotImplementedError('courses_gradebook_fetch') 367 368 def courses_gradebook_list(self, 369 course_id: str, 370 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 371 """ 372 List the full gradebook associated with this course. 373 """ 374 375 return self.courses_gradebook_get(lms.model.courses.CourseQuery(id = course_id), [], [], **kwargs) 376 377 def courses_gradebook_resolve_and_list(self, 378 course_query: lms.model.courses.CourseQuery, 379 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 380 """ 381 List the full gradebook associated with this course. 382 """ 383 384 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 385 return self.courses_gradebook_list(resolved_course_query.get_id(), **kwargs) 386 387 def courses_gradebook_resolve_and_upload(self, 388 course_query: lms.model.courses.CourseQuery, 389 gradebook: lms.model.scores.Gradebook, 390 **kwargs: typing.Any) -> int: 391 """ 392 Resolve queries and upload a gradebook. 393 Missing scores in the gradebook are skipped, 394 a None score (ScoreFragment.score) indicates that the score should be cleared. 395 Return the number of scores sent to the LMS. 396 """ 397 398 if (len(gradebook) == 0): 399 return 0 400 401 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 402 403 assignments = self.courses_assignments_list(resolved_course_query.get_id(), **kwargs) 404 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 405 406 resolved_assignment_queries = [assignment.to_query() for assignment in assignments] 407 resolved_user_queries = [user.to_query() for user in users] 408 409 gradebook.update_queries(resolved_assignment_queries, resolved_user_queries) 410 411 return self.courses_gradebook_upload( 412 resolved_course_query.get_id(), 413 gradebook, 414 **kwargs) 415 416 def courses_gradebook_upload(self, 417 course_id: str, 418 gradebook: lms.model.scores.Gradebook, 419 **kwargs: typing.Any) -> int: 420 """ 421 Upload a gradebook. 422 All queries in the gradebook must be resolved (or at least have an ID). 423 Missing scores in the gradebook are skipped, 424 a None score (ScoreFragment.score) indicates that the score should be cleared. 425 Return the number of scores sent to the LMS. 426 """ 427 428 assignment_scores = gradebook.get_scores_by_assignment() 429 430 count = 0 431 for (assignment, user_scores) in assignment_scores.items(): 432 if (assignment.id is None): 433 raise ValueError(f"Assignment query for gradebook upload ({assignment}) does not have an ID.") 434 435 upload_scores = {} 436 for (user, score) in user_scores.items(): 437 if (user.id is None): 438 raise ValueError(f"User query for gradebook upload ({user}) does not have an ID.") 439 440 upload_scores[user.id] = score.to_fragment() 441 442 count += self.courses_assignments_scores_upload(course_id, assignment.id, upload_scores, **kwargs) 443 444 return count 445 446 def courses_groupsets_create(self, 447 course_id: str, 448 name: str, 449 **kwargs: typing.Any) -> lms.model.groupsets.GroupSet: 450 """ 451 Create a group set. 452 """ 453 454 raise NotImplementedError('courses_groupsets_create') 455 456 def courses_groupsets_resolve_and_create(self, 457 course_query: lms.model.courses.CourseQuery, 458 name: str, 459 **kwargs: typing.Any) -> lms.model.groupsets.GroupSet: 460 """ 461 Resolve references and create a group set. 462 """ 463 464 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 465 return self.courses_groupsets_create(resolved_course_query.get_id(), name, **kwargs) 466 467 def courses_groupsets_delete(self, 468 course_id: str, 469 groupset_id: str, 470 **kwargs: typing.Any) -> bool: 471 """ 472 Delete a group set. 473 """ 474 475 raise NotImplementedError('courses_groupsets_delete') 476 477 def courses_groupsets_resolve_and_delete(self, 478 course_query: lms.model.courses.CourseQuery, 479 groupset_query: lms.model.groupsets.GroupSetQuery, 480 **kwargs: typing.Any) -> bool: 481 """ 482 Resolve references and create a group set. 483 """ 484 485 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 486 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 487 return self.courses_groupsets_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 488 489 def courses_groupsets_get(self, 490 course_query: lms.model.courses.CourseQuery, 491 groupset_queries: typing.Collection[lms.model.groupsets.GroupSetQuery], 492 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 493 """ 494 Get the specified group sets associated with the given course. 495 """ 496 497 if (len(groupset_queries) == 0): 498 return [] 499 500 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 501 groupset_queries = sorted(groupset_queries) 502 groupsets = sorted(self.courses_groupsets_list(resolved_course_query.get_id(), **kwargs)) 503 504 matches = [] 505 for groupset in groupsets: 506 for query in groupset_queries: 507 if (query.match(groupset)): 508 matches.append(groupset) 509 break 510 511 return matches 512 513 def courses_groupsets_fetch(self, 514 course_id: str, 515 groupset_id: str, 516 **kwargs: typing.Any) -> typing.Union[lms.model.groupsets.GroupSet, None]: 517 """ 518 Fetch a single group set associated with the given course. 519 Return None if no matching group set is found. 520 521 By default, this will just do a list and choose the relevant record. 522 Specific backends may override this if there are performance concerns. 523 """ 524 525 groupsets = self.courses_groupsets_list(course_id, **kwargs) 526 for groupset in groupsets: 527 if (groupset.id == groupset_id): 528 return groupset 529 530 return None 531 532 def courses_groupsets_list(self, 533 course_id: str, 534 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 535 """ 536 List the group sets associated with the given course. 537 """ 538 539 raise NotImplementedError('courses_groupsets_list') 540 541 def courses_groupsets_resolve_and_list(self, 542 course_query: lms.model.courses.CourseQuery, 543 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 544 """ 545 List the group sets associated with the given course. 546 """ 547 548 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 549 return sorted(self.courses_groupsets_list(resolved_course_query.get_id(), **kwargs)) 550 551 def courses_groupsets_memberships_resolve_and_add(self, 552 course_query: lms.model.courses.CourseQuery, 553 groupset_query: lms.model.groupsets.GroupSetQuery, 554 memberships: typing.Collection[lms.model.groups.GroupMembership], 555 **kwargs: typing.Any) -> typing.Tuple[ 556 typing.List[lms.model.groups.Group], 557 typing.Dict[lms.model.groups.ResolvedGroupQuery, int] 558 ]: 559 """ 560 Resolve queries and add the specified users to the specified groups. 561 This may create groups. 562 563 Return: 564 - Created Groups 565 - Group Addition Counts 566 """ 567 568 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 569 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 570 571 found_group_memberships, missing_group_memberships, _ = self._resolve_group_memberships( 572 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 573 574 # Create missing groups. 575 created_groups = [] 576 for name in sorted(missing_group_memberships.keys()): 577 group = self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 578 created_groups.append(group) 579 580 # Merge in new group with existing structure. 581 query = group.to_query() 582 if (query not in found_group_memberships): 583 found_group_memberships[query] = [] 584 585 found_group_memberships[query] += missing_group_memberships[name] 586 587 # Add memberships. 588 counts = {} 589 for resolved_group_query in sorted(found_group_memberships.keys()): 590 resolved_user_queries = found_group_memberships[resolved_group_query] 591 592 count = self.courses_groups_memberships_resolve_and_add( 593 resolved_course_query, resolved_groupset_query, resolved_group_query, 594 resolved_user_queries, 595 **kwargs) 596 597 counts[resolved_group_query] = count 598 599 return (created_groups, counts) 600 601 def courses_groupsets_memberships_resolve_and_set(self, 602 course_query: lms.model.courses.CourseQuery, 603 groupset_query: lms.model.groupsets.GroupSetQuery, 604 memberships: typing.Collection[lms.model.groups.GroupMembership], 605 **kwargs: typing.Any) -> typing.Tuple[ 606 typing.List[lms.model.groups.Group], 607 typing.List[lms.model.groups.ResolvedGroupQuery], 608 typing.Dict[lms.model.groups.ResolvedGroupQuery, int], 609 typing.Dict[lms.model.groups.ResolvedGroupQuery, int], 610 ]: 611 """ 612 Resolve queries and set the specified group memberships. 613 This may create and delete groups. 614 615 Return: 616 - Created Groups 617 - Deleted Groups 618 - Group Addition Counts 619 - Group Subtraction Counts 620 """ 621 622 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 623 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 624 625 found_group_memberships, missing_group_memberships, unused_groups = self._resolve_group_memberships( 626 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 627 628 # Delete unused groups. 629 deleted_groups = [] 630 for group_query in sorted(unused_groups): 631 result = self.courses_groups_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query.get_id(), **kwargs) 632 if (result): 633 deleted_groups.append(group_query) 634 635 # Create missing groups. 636 created_groups = [] 637 for name in sorted(missing_group_memberships.keys()): 638 group = self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 639 created_groups.append(group) 640 641 # Merge in new group with existing structure. 642 query = group.to_query() 643 if (query not in found_group_memberships): 644 found_group_memberships[query] = [] 645 646 found_group_memberships[query] += missing_group_memberships[name] 647 648 # Set memberships. 649 add_counts = {} 650 sub_counts = {} 651 for resolved_group_query in sorted(found_group_memberships.keys()): 652 resolved_user_queries = found_group_memberships[resolved_group_query] 653 654 (add_count, sub_count, deleted) = self.courses_groups_memberships_resolve_and_set( 655 resolved_course_query, resolved_groupset_query, resolved_group_query, 656 resolved_user_queries, 657 delete_empty = True, 658 **kwargs) 659 660 if (deleted): 661 deleted_groups.append(resolved_group_query) 662 663 add_counts[resolved_group_query] = add_count 664 sub_counts[resolved_group_query] = sub_count 665 666 return (created_groups, deleted_groups, add_counts, sub_counts) 667 668 def courses_groupsets_memberships_resolve_and_subtract(self, 669 course_query: lms.model.courses.CourseQuery, 670 groupset_query: lms.model.groupsets.GroupSetQuery, 671 memberships: typing.Collection[lms.model.groups.GroupMembership], 672 **kwargs: typing.Any) -> typing.Dict[lms.model.groups.ResolvedGroupQuery, int]: 673 """ 674 Resolve queries and subtract the specified users to the specified groups. 675 This will not delete any groups. 676 677 Return: 678 - Group Subtraction Counts 679 """ 680 681 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 682 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 683 684 found_group_memberships, missing_group_memberships, _ = self._resolve_group_memberships( 685 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 686 687 # Warn about missing groups. 688 for name in sorted(missing_group_memberships.keys()): 689 _logger.warning("Group does not exist: '%s'.", name) 690 691 # Subtract memberships. 692 counts = {} 693 for resolved_group_query in sorted(found_group_memberships.keys()): 694 resolved_user_queries = found_group_memberships[resolved_group_query] 695 696 (count, _) = self.courses_groups_memberships_resolve_and_subtract( 697 resolved_course_query, resolved_groupset_query, resolved_group_query, 698 resolved_user_queries, 699 delete_empty = False, 700 **kwargs) 701 702 counts[resolved_group_query] = count 703 704 return counts 705 706 def courses_groupsets_memberships_list(self, 707 course_id: str, 708 groupset_id: str, 709 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 710 """ 711 List the membership of the group sets associated with the given course. 712 """ 713 714 raise NotImplementedError('courses_groupsets_memberships_list') 715 716 def courses_groupsets_memberships_resolve_and_list(self, 717 course_query: lms.model.courses.CourseQuery, 718 groupset_query: lms.model.groupsets.GroupSetQuery, 719 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 720 """ 721 List the membership of the group sets associated with the given course. 722 """ 723 724 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 725 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 726 727 memberships = self.courses_groupsets_memberships_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 728 729 # Resolve memberships. 730 731 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 732 groups = self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 733 734 users_map = {user.id: user.to_query() for user in users} 735 groups_map = {group.id: group.to_query() for group in groups} 736 737 for membership in memberships: 738 membership.update_queries(resolved_groupset_query, users = users_map, groups = groups_map) 739 740 return sorted(memberships) 741 742 def courses_groups_create(self, 743 course_id: str, 744 groupset_id: str, 745 name: str, 746 **kwargs: typing.Any) -> lms.model.groups.Group: 747 """ 748 Create a group. 749 """ 750 751 raise NotImplementedError('courses_groups_create') 752 753 def courses_groups_resolve_and_create(self, 754 course_query: lms.model.courses.CourseQuery, 755 groupset_query: lms.model.groupsets.GroupSetQuery, 756 name: str, 757 **kwargs: typing.Any) -> lms.model.groups.Group: 758 """ 759 Resolve references and create a group. 760 """ 761 762 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 763 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 764 return self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 765 766 def courses_groups_delete(self, 767 course_id: str, 768 groupset_id: str, 769 group_id: str, 770 **kwargs: typing.Any) -> bool: 771 """ 772 Delete a group. 773 """ 774 775 raise NotImplementedError('courses_groups_delete') 776 777 def courses_groups_resolve_and_delete(self, 778 course_query: lms.model.courses.CourseQuery, 779 groupset_query: lms.model.groupsets.GroupSetQuery, 780 group_query: lms.model.groups.GroupQuery, 781 **kwargs: typing.Any) -> bool: 782 """ 783 Resolve references and create a group. 784 """ 785 786 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 787 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 788 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 789 return self.courses_groups_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), resolved_group_query.get_id(), **kwargs) 790 791 def courses_groups_get(self, 792 course_query: lms.model.courses.CourseQuery, 793 groupset_query: lms.model.groupsets.GroupSetQuery, 794 group_queries: typing.Collection[lms.model.groups.GroupQuery], 795 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 796 """ 797 Get the specified groups associated with the given course. 798 """ 799 800 if (len(group_queries) == 0): 801 return [] 802 803 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 804 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 805 groups = self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 806 807 group_queries = sorted(group_queries) 808 groups = sorted(groups) 809 810 matches = [] 811 for group in groups: 812 for query in group_queries: 813 if (query.match(group)): 814 matches.append(group) 815 break 816 817 return matches 818 819 def courses_groups_fetch(self, 820 course_id: str, 821 groupset_id: str, 822 group_id: str, 823 **kwargs: typing.Any) -> typing.Union[lms.model.groups.Group, None]: 824 """ 825 Fetch a single group associated with the given course. 826 Return None if no matching group is found. 827 828 By default, this will just do a list and choose the relevant record. 829 Specific backends may override this if there are performance concerns. 830 """ 831 832 groups = self.courses_groups_list(course_id, groupset_id, **kwargs) 833 for group in groups: 834 if (group.id == group_id): 835 return group 836 837 return None 838 839 def courses_groups_list(self, 840 course_id: str, 841 groupset_id: str, 842 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 843 """ 844 List the groups associated with the given course. 845 """ 846 847 raise NotImplementedError('courses_groups_list') 848 849 def courses_groups_resolve_and_list(self, 850 course_query: lms.model.courses.CourseQuery, 851 groupset_query: lms.model.groupsets.GroupSetQuery, 852 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 853 """ 854 List the groups associated with the given course. 855 """ 856 857 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 858 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 859 return self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 860 861 def courses_groups_memberships_add(self, 862 course_id: str, 863 groupset_id: str, 864 group_id: str, 865 user_ids: typing.Collection[str], 866 **kwargs: typing.Any) -> int: 867 """ 868 Add the specified users to the group. 869 """ 870 871 raise NotImplementedError('courses_groups_memberships_add') 872 873 def courses_groups_memberships_resolve_and_add(self, 874 course_query: lms.model.courses.CourseQuery, 875 groupset_query: lms.model.groupsets.GroupSetQuery, 876 group_query: lms.model.groups.GroupQuery, 877 user_queries: typing.Collection[lms.model.users.UserQuery], 878 **kwargs: typing.Any) -> int: 879 """ 880 Resolve queries and add the specified users to the group. 881 """ 882 883 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 884 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 885 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 886 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 887 888 # Get users already in this group. 889 group_memberships = self.courses_groups_memberships_list( 890 resolved_course_query.get_id(), 891 resolved_groupset_query.get_id(), 892 resolved_group_query.get_id(), 893 **kwargs) 894 895 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 896 897 # Filter out users already in the group. 898 user_ids = [] 899 for query in sorted(resolved_user_queries): 900 if (query.get_id() in group_user_ids): 901 _logger.warning("User '%s' already in group '%s'.", query, resolved_group_query) 902 continue 903 904 user_ids.append(query.get_id()) 905 906 if (len(user_ids) == 0): 907 return 0 908 909 return self.courses_groups_memberships_add( 910 resolved_course_query.get_id(), 911 resolved_groupset_query.get_id(), 912 resolved_group_query.get_id(), 913 user_ids, 914 **kwargs) 915 916 def courses_groups_memberships_list(self, 917 course_id: str, 918 groupset_id: str, 919 group_id: str, 920 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 921 """ 922 List the membership of the group associated with the given group set. 923 """ 924 925 raise NotImplementedError('courses_groups_memberships_list') 926 927 def courses_groups_memberships_resolve_and_list(self, 928 course_query: lms.model.courses.CourseQuery, 929 groupset_query: lms.model.groupsets.GroupSetQuery, 930 group_query: lms.model.groups.GroupQuery, 931 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 932 """ 933 List the membership of the group associated with the given group set. 934 """ 935 936 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 937 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 938 939 groups = self.courses_groups_get(resolved_course_query, resolved_groupset_query, [group_query], **kwargs) 940 if (len(groups) == 0): 941 raise ValueError(f"Unable to find group: '{group_query}'.") 942 943 group = groups[0] 944 945 memberships = self.courses_groups_memberships_list( 946 resolved_course_query.get_id(), 947 resolved_groupset_query.get_id(), 948 group.id, 949 **kwargs) 950 951 # Resolve memberships. 952 953 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 954 users_map = {user.id: user.to_query() for user in users} 955 956 groups_map = {group.id: group.to_query()} 957 958 for membership in memberships: 959 membership.update_queries(resolved_groupset_query, users = users_map, groups = groups_map) 960 961 return sorted(memberships) 962 963 def courses_groups_memberships_resolve_and_set(self, 964 course_query: lms.model.courses.CourseQuery, 965 groupset_query: lms.model.groupsets.GroupSetQuery, 966 group_query: lms.model.groups.GroupQuery, 967 user_queries: typing.Collection[lms.model.users.UserQuery], 968 delete_empty: bool = False, 969 **kwargs: typing.Any) -> typing.Tuple[int, int, bool]: 970 """ 971 Resolve queries and set the specified users for the group. 972 This method can both add and subtract users from the group. 973 974 Returns: 975 - The count of users added. 976 - The count of users subtracted. 977 - If this group was deleted. 978 """ 979 980 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 981 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 982 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 983 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 984 985 # Get users already in this group. 986 group_memberships = self.courses_groups_memberships_list( 987 resolved_course_query.get_id(), 988 resolved_groupset_query.get_id(), 989 resolved_group_query.get_id(), 990 **kwargs) 991 992 group_user_queries = {membership.user for membership in group_memberships if membership.user is not None} 993 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 994 query_user_ids = {resolved_user_query.get_id() for resolved_user_query in resolved_user_queries} 995 996 # Collect users that need to be added. 997 add_user_ids = [] 998 for query_user_id in query_user_ids: 999 if (query_user_id not in group_user_ids): 1000 add_user_ids.append(query_user_id) 1001 1002 # Collect users that need to be subtracted. 1003 sub_user_queries = [] 1004 for group_user_query in group_user_queries: 1005 if (group_user_query not in resolved_user_queries): 1006 sub_user_queries.append(group_user_query) 1007 1008 # Update the group. 1009 1010 add_count = 0 1011 if (len(add_user_ids) > 0): 1012 add_count = self.courses_groups_memberships_add( 1013 resolved_course_query.get_id(), 1014 resolved_groupset_query.get_id(), 1015 resolved_group_query.get_id(), 1016 add_user_ids, 1017 **kwargs) 1018 1019 sub_count = 0 1020 deleted = False 1021 if (len(sub_user_queries) > 0): 1022 sub_count, deleted = self.courses_groups_memberships_resolve_and_subtract( 1023 resolved_course_query, 1024 resolved_groupset_query, 1025 resolved_group_query, 1026 sub_user_queries, 1027 delete_empty = delete_empty, 1028 **kwargs) 1029 1030 return add_count, sub_count, deleted 1031 1032 def courses_groups_memberships_subtract(self, 1033 course_id: str, 1034 groupset_id: str, 1035 group_id: str, 1036 user_ids: typing.Collection[str], 1037 **kwargs: typing.Any) -> int: 1038 """ 1039 Subtract the specified users from the group. 1040 """ 1041 1042 raise NotImplementedError('courses_groups_memberships_subtract') 1043 1044 def courses_groups_memberships_resolve_and_subtract(self, 1045 course_query: lms.model.courses.CourseQuery, 1046 groupset_query: lms.model.groupsets.GroupSetQuery, 1047 group_query: lms.model.groups.GroupQuery, 1048 user_queries: typing.Collection[lms.model.users.UserQuery], 1049 delete_empty: bool = False, 1050 **kwargs: typing.Any) -> typing.Tuple[int, bool]: 1051 """ 1052 Resolve queries and subtract the specified users from the group. 1053 Return: 1054 - The number of users deleted. 1055 - If this group was deleted. 1056 """ 1057 1058 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1059 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 1060 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 1061 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 1062 1063 # Get users already in this group. 1064 group_memberships = self.courses_groups_memberships_list( 1065 resolved_course_query.get_id(), 1066 resolved_groupset_query.get_id(), 1067 resolved_group_query.get_id(), 1068 **kwargs) 1069 1070 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 1071 1072 # Filter out users not in the group. 1073 user_ids = [] 1074 for query in resolved_user_queries: 1075 if (query.get_id() not in group_user_ids): 1076 _logger.warning("User '%s' is not in group '%s'.", query, resolved_group_query) 1077 continue 1078 1079 user_ids.append(query.get_id()) 1080 1081 if (delete_empty and len(group_memberships) == 0): 1082 deleted = self.courses_groups_delete( 1083 resolved_course_query.get_id(), 1084 resolved_groupset_query.get_id(), 1085 resolved_group_query.get_id(), 1086 **kwargs) 1087 return 0, deleted 1088 1089 if (len(user_ids) == 0): 1090 return 0, False 1091 1092 count = self.courses_groups_memberships_subtract( 1093 resolved_course_query.get_id(), 1094 resolved_groupset_query.get_id(), 1095 resolved_group_query.get_id(), 1096 user_ids, 1097 **kwargs) 1098 1099 deleted = False 1100 if (delete_empty and (count == len(group_memberships))): 1101 deleted = self.courses_groups_delete( 1102 resolved_course_query.get_id(), 1103 resolved_groupset_query.get_id(), 1104 resolved_group_query.get_id(), 1105 **kwargs) 1106 1107 return count, deleted 1108 1109 def courses_syllabus_fetch(self, 1110 course_id: str, 1111 **kwargs: typing.Any) -> typing.Union[str, None]: 1112 """ 1113 Get the syllabus for a course, or None if no syllabus exists. 1114 """ 1115 1116 raise NotImplementedError('courses_syllabus_fetch') 1117 1118 def courses_syllabus_get(self, 1119 course_query: lms.model.courses.CourseQuery, 1120 **kwargs: typing.Any) -> typing.Union[str, None]: 1121 """ 1122 Get the syllabus for a course query, or None if no syllabus exists. 1123 """ 1124 1125 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1126 1127 return self.courses_syllabus_fetch(resolved_course_query.get_id(), **kwargs) 1128 1129 def courses_users_get(self, 1130 course_query: lms.model.courses.CourseQuery, 1131 user_queries: typing.Collection[lms.model.users.UserQuery], 1132 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1133 """ 1134 Get the specified users associated with the given course. 1135 """ 1136 1137 if (len(user_queries) == 0): 1138 return [] 1139 1140 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1141 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 1142 1143 user_queries = sorted(user_queries) 1144 users = sorted(users) 1145 1146 matches = [] 1147 for user in users: 1148 for query in user_queries: 1149 if (query.match(user)): 1150 matches.append(user) 1151 break 1152 1153 return matches 1154 1155 def courses_users_fetch(self, 1156 course_id: str, 1157 user_id: str, 1158 **kwargs: typing.Any) -> typing.Union[lms.model.users.CourseUser, None]: 1159 """ 1160 Fetch a single user associated with the given course. 1161 Return None if no matching user is found. 1162 1163 By default, this will just do a list and choose the relevant record. 1164 Specific backends may override this if there are performance concerns. 1165 """ 1166 1167 users = self.courses_users_list(course_id, **kwargs) 1168 for user in sorted(users): 1169 if (user.id == user_id): 1170 return user 1171 1172 return None 1173 1174 def courses_users_list(self, 1175 course_id: str, 1176 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1177 """ 1178 List the users associated with the given course. 1179 """ 1180 1181 raise NotImplementedError('courses_users_list') 1182 1183 def courses_users_resolve_and_list(self, 1184 course_query: lms.model.courses.CourseQuery, 1185 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1186 """ 1187 List the users associated with the given course. 1188 """ 1189 1190 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1191 return list(sorted(self.courses_users_list(resolved_course_query.get_id(), **kwargs))) 1192 1193 def courses_users_scores_get(self, 1194 course_query: lms.model.courses.CourseQuery, 1195 user_query: lms.model.users.UserQuery, 1196 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 1197 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1198 """ 1199 Get the scores associated with the given user query and assignment queries. 1200 """ 1201 1202 if (len(assignment_queries) == 0): 1203 return [] 1204 1205 scores = self.courses_users_scores_resolve_and_list(course_query, user_query, **kwargs) 1206 1207 scores = sorted(scores) 1208 assignment_queries = sorted(assignment_queries) 1209 1210 matches = [] 1211 for score in scores: 1212 for assignment_query in assignment_queries: 1213 if (assignment_query.match(score.assignment)): 1214 matches.append(score) 1215 1216 return matches 1217 1218 def courses_users_scores_fetch(self, 1219 course_id: str, 1220 user_id: str, 1221 assignment_id: str, 1222 **kwargs: typing.Any) -> typing.Union[lms.model.scores.AssignmentScore, None]: 1223 """ 1224 Fetch the score associated with the given user and assignment. 1225 1226 By default, this will just do a list and choose the relevant record. 1227 Specific backends may override this if there are performance concerns. 1228 """ 1229 1230 # The default implementation is the same as courses_assignments_scores_fetch(). 1231 return self.courses_assignments_scores_fetch(course_id, assignment_id, user_id, **kwargs) 1232 1233 def courses_users_scores_list(self, 1234 course_id: str, 1235 user_id: str, 1236 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1237 """ 1238 List the scores associated with the given user. 1239 """ 1240 1241 raise NotImplementedError('courses_users_scores_list') 1242 1243 def courses_users_scores_resolve_and_list(self, 1244 course_query: lms.model.courses.CourseQuery, 1245 user_query: lms.model.users.UserQuery, 1246 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1247 """ 1248 List the scores associated with the given user query. 1249 In addition to resolving the user query, 1250 assignments will also be resolved into their full version 1251 (instead of the reduced version usually returned with scores). 1252 """ 1253 1254 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1255 1256 # Resolve the user query. 1257 matched_users = self.courses_users_get(resolved_course_query, [user_query], **kwargs) 1258 if (len(matched_users) == 0): 1259 return [] 1260 1261 target_user = matched_users[0] 1262 1263 # List the scores. 1264 scores = self.courses_users_scores_list(resolved_course_query.get_id(), target_user.id, **kwargs) 1265 if (len(scores) == 0): 1266 return [] 1267 1268 # Resolve the scores' queries. 1269 1270 assignments = self.courses_assignments_list(resolved_course_query.get_id(), **kwargs) 1271 assignments_map = {assignment.id: assignment for assignment in assignments} 1272 1273 for score in scores: 1274 score.user = target_user.to_query() 1275 1276 if ((score.assignment is not None) and (score.assignment.id in assignments_map)): 1277 score.assignment = assignments_map[score.assignment.id].to_query() 1278 1279 return sorted(scores) 1280 1281 # Utility Methods 1282 1283 def parse_assignment_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.assignments.AssignmentQuery, None]: 1284 """ 1285 Attempt to parse an assignment query from a string. 1286 The there is no query, return a None. 1287 If the query is malformed, raise an exception. 1288 1289 By default, this method assumes that LMS IDs are ints. 1290 Child backends may override this to implement their specific behavior. 1291 """ 1292 1293 return lms.model.query.parse_int_query(lms.model.assignments.AssignmentQuery, text, check_email = False) 1294 1295 def parse_assignment_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.assignments.AssignmentQuery]: 1296 """ Parse a list of assignment queries. """ 1297 1298 queries = [] 1299 for text in texts: 1300 query = self.parse_assignment_query(text) 1301 if (query is not None): 1302 queries.append(query) 1303 1304 return queries 1305 1306 def parse_course_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.courses.CourseQuery, None]: 1307 """ 1308 Attempt to parse a course query from a string. 1309 The there is no query, return a None. 1310 If the query is malformed, raise an exception. 1311 1312 By default, this method assumes that LMS IDs are ints. 1313 Child backends may override this to implement their specific behavior. 1314 """ 1315 1316 return lms.model.query.parse_int_query(lms.model.courses.CourseQuery, text, check_email = False) 1317 1318 def parse_course_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.courses.CourseQuery]: 1319 """ Parse a list of course queries. """ 1320 1321 queries = [] 1322 for text in texts: 1323 query = self.parse_course_query(text) 1324 if (query is not None): 1325 queries.append(query) 1326 1327 return queries 1328 1329 def parse_groupset_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.groupsets.GroupSetQuery, None]: 1330 """ 1331 Attempt to parse a group set query from a string. 1332 The there is no query, return a None. 1333 If the query is malformed, raise an exception. 1334 1335 By default, this method assumes that LMS IDs are ints. 1336 Child backends may override this to implement their specific behavior. 1337 """ 1338 1339 return lms.model.query.parse_int_query(lms.model.groupsets.GroupSetQuery, text, check_email = False) 1340 1341 def parse_groupset_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.groupsets.GroupSetQuery]: 1342 """ Parse a list of group set queries. """ 1343 1344 queries = [] 1345 for text in texts: 1346 query = self.parse_groupset_query(text) 1347 if (query is not None): 1348 queries.append(query) 1349 1350 return queries 1351 1352 def parse_group_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.groups.GroupQuery, None]: 1353 """ 1354 Attempt to parse a group query from a string. 1355 The there is no query, return a None. 1356 If the query is malformed, raise an exception. 1357 1358 By default, this method assumes that LMS IDs are ints. 1359 Child backends may override this to implement their specific behavior. 1360 """ 1361 1362 return lms.model.query.parse_int_query(lms.model.groups.GroupQuery, text, check_email = False) 1363 1364 def parse_group_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.groups.GroupQuery]: 1365 """ Parse a list of group queries. """ 1366 1367 queries = [] 1368 for text in texts: 1369 query = self.parse_group_query(text) 1370 if (query is not None): 1371 queries.append(query) 1372 1373 return queries 1374 1375 def parse_user_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.users.UserQuery, None]: 1376 """ 1377 Attempt to parse a user query from a string. 1378 The there is no query, return a None. 1379 If the query is malformed, raise an exception. 1380 1381 By default, this method assumes that LMS IDs are ints. 1382 Child backends may override this to implement their specific behavior. 1383 """ 1384 1385 return lms.model.query.parse_int_query(lms.model.users.UserQuery, text, check_email = True) 1386 1387 def parse_user_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.users.UserQuery]: 1388 """ Parse a list of user queries. """ 1389 1390 queries = [] 1391 for text in texts: 1392 query = self.parse_user_query(text) 1393 if (query is not None): 1394 queries.append(query) 1395 1396 return queries 1397 1398 def resolve_assignment_query(self, 1399 course_id: str, 1400 assignment_query: lms.model.assignments.AssignmentQuery, 1401 **kwargs: typing.Any) -> lms.model.assignments.ResolvedAssignmentQuery: 1402 """ Resolve the assignment query or raise an exception. """ 1403 1404 # Shortcut already resolved queries. 1405 if (isinstance(assignment_query, lms.model.assignments.ResolvedAssignmentQuery)): 1406 return assignment_query 1407 1408 results = self.resolve_assignment_queries(course_id, [assignment_query], **kwargs) 1409 if (len(results) == 0): 1410 raise ValueError(f"Could not resolve assignment query: '{assignment_query}'.") 1411 1412 return results[0] 1413 1414 def resolve_assignment_queries(self, 1415 course_id: str, 1416 queries: typing.Collection[lms.model.assignments.AssignmentQuery], 1417 **kwargs: typing.Any) -> typing.List[lms.model.assignments.ResolvedAssignmentQuery]: 1418 """ 1419 Resolve a list of assignment queries into a list of resolved assignment queries. 1420 See _resolve_queries(). 1421 """ 1422 1423 results = self._resolve_queries( 1424 queries, 1425 'assignment', 1426 self.courses_assignments_list(course_id, **kwargs), 1427 lms.model.assignments.ResolvedAssignmentQuery, 1428 **kwargs) 1429 1430 return typing.cast(typing.List[lms.model.assignments.ResolvedAssignmentQuery], results) 1431 1432 def resolve_course_query(self, 1433 query: lms.model.courses.CourseQuery, 1434 **kwargs: typing.Any) -> lms.model.courses.ResolvedCourseQuery: 1435 """ Resolve the course query or raise an exception. """ 1436 1437 # Shortcut already resolved queries. 1438 if (isinstance(query, lms.model.courses.ResolvedCourseQuery)): 1439 return query 1440 1441 results = self.resolve_course_queries([query], **kwargs) 1442 if (len(results) == 0): 1443 raise ValueError(f"Could not resolve course query: '{query}'.") 1444 1445 return results[0] 1446 1447 def resolve_course_queries(self, 1448 queries: typing.Collection[lms.model.courses.CourseQuery], 1449 **kwargs: typing.Any) -> typing.List[lms.model.courses.ResolvedCourseQuery]: 1450 """ 1451 Resolve a list of course queries into a list of resolved course queries. 1452 See _resolve_queries(). 1453 """ 1454 1455 results = self._resolve_queries( 1456 queries, 1457 'course', 1458 self.courses_list(**kwargs), 1459 lms.model.courses.ResolvedCourseQuery, 1460 **kwargs) 1461 1462 return typing.cast(typing.List[lms.model.courses.ResolvedCourseQuery], results) 1463 1464 def resolve_group_queries(self, 1465 course_id: str, 1466 groupset_id: str, 1467 queries: typing.Collection[lms.model.groups.GroupQuery], 1468 **kwargs: typing.Any) -> typing.List[lms.model.groups.ResolvedGroupQuery]: 1469 """ 1470 Resolve a list of group queries into a list of resolved group queries. 1471 See _resolve_queries(). 1472 """ 1473 1474 results = self._resolve_queries( 1475 queries, 1476 'group', 1477 self.courses_groups_list(course_id, groupset_id, **kwargs), 1478 lms.model.groups.ResolvedGroupQuery, 1479 **kwargs) 1480 1481 return typing.cast(typing.List[lms.model.groups.ResolvedGroupQuery], results) 1482 1483 def resolve_group_query(self, 1484 course_id: str, 1485 groupset_id: str, 1486 query: lms.model.groups.GroupQuery, 1487 **kwargs: typing.Any) -> lms.model.groups.ResolvedGroupQuery: 1488 """ Resolve the group query or raise an exception. """ 1489 1490 # Shortcut already resolved queries. 1491 if (isinstance(query, lms.model.groups.ResolvedGroupQuery)): 1492 return query 1493 1494 results = self.resolve_group_queries(course_id, groupset_id, [query], **kwargs) 1495 if (len(results) == 0): 1496 raise ValueError(f"Could not resolve group query: '{query}'.") 1497 1498 return results[0] 1499 1500 def resolve_groupset_queries(self, 1501 course_id: str, 1502 queries: typing.Collection[lms.model.groupsets.GroupSetQuery], 1503 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.ResolvedGroupSetQuery]: 1504 """ 1505 Resolve a list of group set queries into a list of resolved group set queries. 1506 See _resolve_queries(). 1507 """ 1508 1509 results = self._resolve_queries( 1510 queries, 1511 'group set', 1512 self.courses_groupsets_list(course_id, **kwargs), 1513 lms.model.groupsets.ResolvedGroupSetQuery, 1514 **kwargs) 1515 1516 return typing.cast(typing.List[lms.model.groupsets.ResolvedGroupSetQuery], results) 1517 1518 def resolve_groupset_query(self, 1519 course_id: str, 1520 groupset_query: lms.model.groupsets.GroupSetQuery, 1521 **kwargs: typing.Any) -> lms.model.groupsets.ResolvedGroupSetQuery: 1522 """ Resolve the group set query or raise an exception. """ 1523 1524 # Shortcut already resolved queries. 1525 if (isinstance(groupset_query, lms.model.groupsets.ResolvedGroupSetQuery)): 1526 return groupset_query 1527 1528 results = self.resolve_groupset_queries(course_id, [groupset_query], **kwargs) 1529 if (len(results) == 0): 1530 raise ValueError(f"Could not resolve group set query: '{groupset_query}'.") 1531 1532 return results[0] 1533 1534 def resolve_user_queries(self, 1535 course_id: str, 1536 queries: typing.Collection[lms.model.users.UserQuery], 1537 only_students: bool = False, 1538 **kwargs: typing.Any) -> typing.List[lms.model.users.ResolvedUserQuery]: 1539 """ 1540 Resolve a list of user queries into a list of resolved user queries. 1541 See _resolve_queries(). 1542 """ 1543 1544 filter_func = None 1545 if (only_students): 1546 filter_func = lambda user: user.is_student() # pylint: disable=unnecessary-lambda-assignment 1547 1548 results = self._resolve_queries( 1549 queries, 1550 'user', 1551 self.courses_users_list(course_id, **kwargs), 1552 lms.model.users.ResolvedUserQuery, 1553 filter_func = filter_func, 1554 **kwargs) 1555 1556 return typing.cast(typing.List[lms.model.users.ResolvedUserQuery], results) 1557 1558 def _resolve_queries(self, 1559 queries: typing.Collection[lms.model.query.BaseQuery], 1560 label: str, 1561 items: typing.Collection, 1562 resolved_query_class: typing.Type, 1563 empty_all: bool = False, 1564 warn_on_miss: bool = False, 1565 filter_func: typing.Union[typing.Callable, None] = None, 1566 **kwargs: typing.Any) -> typing.List[lms.model.query.ResolvedBaseQuery]: 1567 """ 1568 Resolve a list of queries. 1569 The returned list may be shorter than the list of queries (if input queries are not matched). 1570 The queries will be deduplicated and sorted. 1571 1572 If |empty_all| is true and no queries are specified, then all items will be returned. 1573 1574 If |filter_func| is passed, then that function will be called with each raw item, 1575 and ones that return true will be kept. 1576 """ 1577 1578 if (filter_func is not None): 1579 items = list(filter(filter_func, items)) 1580 1581 if (empty_all and (len(queries) == 0)): 1582 return list(sorted({resolved_query_class(item) for item in items})) 1583 1584 matched_queries = [] # type: ignore[var-annotated] 1585 for query in queries: 1586 match = False 1587 for item in items: 1588 if (query.match(item)): 1589 matched_query = resolved_query_class(item) 1590 1591 if (match): 1592 raise ValueError( 1593 f"Ambiguous {label} query ('{query}')" 1594 f" matches multiple {label}s ['{matched_queries[-1]}', '{matched_query}'].") 1595 1596 matched_queries.append(matched_query) 1597 match = True 1598 1599 if ((not match) and warn_on_miss): 1600 _logger.warning("Could not resolve %s query '%s'.", label, query) 1601 1602 return list(sorted(set(matched_queries))) 1603 1604 def _resolve_group_memberships(self, 1605 course_id: str, 1606 groupset_id: str, 1607 memberships: typing.Collection[lms.model.groups.GroupMembership], 1608 **kwargs: typing.Any) -> typing.Tuple[ 1609 typing.Dict[lms.model.groups.ResolvedGroupQuery, typing.List[lms.model.users.ResolvedUserQuery]], 1610 typing.Dict[str, typing.List[lms.model.users.ResolvedUserQuery]], 1611 typing.List[lms.model.groups.ResolvedGroupQuery]]: 1612 """ 1613 Resolve a list of group memberships. 1614 This method will resolved each query and split up the memberships by the appropriate group. 1615 If a group does not exist, the memberships will be split by apparent group name. 1616 1617 Returns: 1618 - Memberships in Found Groups (keyed by resolved group query) 1619 - Memberships in Missing Groups (keyed by apparent group name) 1620 - Groups not involved in any of the returned memberships. 1621 1622 The returned dicts will be the found groups (keyed by resolved query) and then the missing groups (keyed by apparent group name). 1623 """ 1624 1625 found_group_memberships: typing.Dict[lms.model.groups.ResolvedGroupQuery, typing.List[lms.model.users.ResolvedUserQuery]] = {} 1626 missing_group_memberships: typing.Dict[str, typing.List[lms.model.users.ResolvedUserQuery]] = {} 1627 1628 users = self.courses_users_list(course_id, **kwargs) 1629 resolved_user_queries = [user.to_query() for user in sorted(users)] 1630 1631 groups = self.courses_groups_list(course_id, groupset_id, **kwargs) 1632 resolved_group_queries = [group.to_query() for group in sorted(groups)] 1633 1634 for (i, membership) in enumerate(memberships): 1635 # Resolve user. 1636 1637 resolved_user_query = None 1638 for possible_user_query in resolved_user_queries: 1639 if (membership.user.match(possible_user_query)): 1640 resolved_user_query = possible_user_query 1641 break 1642 1643 if (resolved_user_query is None): 1644 _logger.warning("Could not resolve user '%s' for membership entry at index %d.", membership.user, i) 1645 continue 1646 1647 # Resolve group. 1648 1649 resolved_group_query = None 1650 for possible_group_query in resolved_group_queries: 1651 if (membership.group.match(possible_group_query)): 1652 resolved_group_query = possible_group_query 1653 break 1654 1655 # Add to the correct collection. 1656 1657 if (resolved_group_query is None): 1658 if ((membership.group.name is None) or (len(membership.group.name) == 0)): 1659 _logger.warning(("Membership entry at index %d has a group with no name." 1660 + " Ensure that non-existent groups all have names."), i) 1661 continue 1662 1663 if (membership.group.name not in missing_group_memberships): 1664 missing_group_memberships[membership.group.name] = [] 1665 1666 missing_group_memberships[membership.group.name].append(resolved_user_query) 1667 else: 1668 if (resolved_group_query not in found_group_memberships): 1669 found_group_memberships[resolved_group_query] = [] 1670 1671 found_group_memberships[resolved_group_query].append(resolved_user_query) 1672 1673 unused_groups = sorted(list(set(resolved_group_queries) - set(found_group_memberships.keys()))) 1674 1675 return (found_group_memberships, missing_group_memberships, unused_groups)
API backends provide a unified interface to an LMS.
Note that instead of using an abstract class, methods will raise a NotImplementedError by default. This will allow child backends to fill in as much functionality as they can, while still leaving gaps where they are incomplete or impossible.
31 def __init__(self, 32 server: str, 33 backend_type: str, 34 testing: typing.Union[bool, str] = False, 35 **kwargs: typing.Any) -> None: 36 self.server: str = server 37 """ The server this backend will connect to. """ 38 39 self.backend_type: str = backend_type 40 """ 41 The type for this backend. 42 Should be set by the child class. 43 """ 44 45 parsed_testing = edq.util.parse.boolean(testing) 46 if (APIBackend._testing_override is not None): 47 parsed_testing = APIBackend._testing_override 48 49 self.testing: bool = parsed_testing 50 """ True if the backend is being used for a test. """
54 def is_testing(self) -> bool: 55 """ Check if this backend is in testing mode. """ 56 57 return self.testing
Check if this backend is in testing mode.
59 def get_standard_headers(self) -> typing.Dict[str, str]: 60 """ 61 Get standard headers for this backend. 62 Children should take care to set the write header when performing a write operation. 63 """ 64 65 return { 66 lms.model.constants.HEADER_KEY_BACKEND: self.backend_type, 67 lms.model.constants.HEADER_KEY_WRITE: 'false', 68 }
Get standard headers for this backend. Children should take care to set the write header when performing a write operation.
70 def not_found(self, operation: str, identifiers: typing.Dict[str, typing.Any]) -> None: 71 """ 72 Called when the backend was unable to find some object. 73 This will only be called when a requested object is not found, 74 e.g., a user requested by ID is not found. 75 This is not called when a list naturally returns zero results, 76 or when a query does not match any items. 77 """ 78 79 _logger.warning("Object not found during operation: '%s'. Identifiers: %s.", operation, identifiers)
Called when the backend was unable to find some object. This will only be called when a requested object is not found, e.g., a user requested by ID is not found. This is not called when a list naturally returns zero results, or when a query does not match any items.
83 def courses_get(self, 84 course_queries: typing.Collection[lms.model.courses.CourseQuery], 85 **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]: 86 """ 87 Get the specified courses associated with the given course. 88 """ 89 90 if (len(course_queries) == 0): 91 return [] 92 93 courses = self.courses_list(**kwargs) 94 95 matches = [] 96 for course in sorted(courses): 97 for query in course_queries: 98 if (query.match(course)): 99 matches.append(course) 100 break 101 102 return sorted(matches)
Get the specified courses associated with the given course.
104 def courses_fetch(self, 105 course_id: str, 106 **kwargs: typing.Any) -> typing.Union[lms.model.courses.Course, None]: 107 """ 108 Fetch a single course associated with the context user. 109 Return None if no matching course is found. 110 111 By default, this will just do a list and choose the relevant record. 112 Specific backends may override this if there are performance concerns. 113 """ 114 115 courses = self.courses_list(**kwargs) 116 for course in courses: 117 if (course.id == course_id): 118 return course 119 120 return None
Fetch a single course associated with the context user. Return None if no matching course is found.
By default, this will just do a list and choose the relevant record. Specific backends may override this if there are performance concerns.
122 def courses_list(self, 123 **kwargs: typing.Any) -> typing.List[lms.model.courses.Course]: 124 """ 125 List the courses associated with the context user. 126 """ 127 128 raise NotImplementedError('courses_list')
List the courses associated with the context user.
130 def courses_assignments_get(self, 131 course_query: lms.model.courses.CourseQuery, 132 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 133 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 134 """ 135 Get the specified assignments associated with the given course. 136 """ 137 138 if (len(assignment_queries) == 0): 139 return [] 140 141 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 142 143 assignments = sorted(self.courses_assignments_list(resolved_course_query.get_id(), **kwargs)) 144 assignment_queries = sorted(assignment_queries) 145 146 matches = [] 147 for assignment in assignments: 148 for query in assignment_queries: 149 if (query.match(assignment)): 150 matches.append(assignment) 151 break 152 153 return matches
Get the specified assignments associated with the given course.
155 def courses_assignments_fetch(self, 156 course_id: str, 157 assignment_id: str, 158 **kwargs: typing.Any) -> typing.Union[lms.model.assignments.Assignment, None]: 159 """ 160 Fetch a single assignment associated with the given course. 161 Return None if no matching assignment is found. 162 163 By default, this will just do a list and choose the relevant record. 164 Specific backends may override this if there are performance concerns. 165 """ 166 167 assignments = self.courses_assignments_list(course_id, **kwargs) 168 for assignment in sorted(assignments): 169 if (assignment.id == assignment_id): 170 return assignment 171 172 return None
Fetch a single assignment associated with the given course. Return None if no matching assignment is found.
By default, this will just do a list and choose the relevant record. Specific backends may override this if there are performance concerns.
174 def courses_assignments_list(self, 175 course_id: str, 176 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 177 """ 178 List the assignments associated with the given course. 179 """ 180 181 raise NotImplementedError('courses_assignments_list')
List the assignments associated with the given course.
183 def courses_assignments_resolve_and_list(self, 184 course_query: lms.model.courses.CourseQuery, 185 **kwargs: typing.Any) -> typing.List[lms.model.assignments.Assignment]: 186 """ 187 List the assignments associated with the given course. 188 """ 189 190 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 191 return sorted(self.courses_assignments_list(resolved_course_query.get_id(), **kwargs))
List the assignments associated with the given course.
193 def courses_assignments_scores_get(self, 194 course_query: lms.model.courses.CourseQuery, 195 assignment_query: lms.model.assignments.AssignmentQuery, 196 user_queries: typing.Collection[lms.model.users.UserQuery], 197 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 198 """ 199 Get the scores associated with the given assignment query and user queries. 200 """ 201 202 if (len(user_queries) == 0): 203 return [] 204 205 scores = self.courses_assignments_scores_resolve_and_list(course_query, assignment_query, **kwargs) 206 207 matches = [] 208 for score in scores: 209 for user_query in user_queries: 210 if (user_query.match(score.user)): 211 matches.append(score) 212 213 return sorted(matches)
Get the scores associated with the given assignment query and user queries.
215 def courses_assignments_scores_fetch(self, 216 course_id: str, 217 assignment_id: str, 218 user_id: str, 219 **kwargs: typing.Any) -> typing.Union[lms.model.scores.AssignmentScore, None]: 220 """ 221 Fetch the score associated with the given assignment and user. 222 223 By default, this will just do a list and choose the relevant record. 224 Specific backends may override this if there are performance concerns. 225 """ 226 227 scores = self.courses_assignments_scores_list(course_id, assignment_id, **kwargs) 228 for score in scores: 229 if ((score.user is not None) and (score.user.id == user_id)): 230 return score 231 232 return None
Fetch the score associated with the given assignment and user.
By default, this will just do a list and choose the relevant record. Specific backends may override this if there are performance concerns.
234 def courses_assignments_scores_list(self, 235 course_id: str, 236 assignment_id: str, 237 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 238 """ 239 List the scores associated with the given assignment. 240 """ 241 242 raise NotImplementedError('courses_assignments_scores_list')
List the scores associated with the given assignment.
244 def courses_assignments_scores_resolve_and_list(self, 245 course_query: lms.model.courses.CourseQuery, 246 assignment_query: lms.model.assignments.AssignmentQuery, 247 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 248 """ 249 List the scores associated with the given assignment query. 250 In addition to resolving the assignment query, 251 users will also be resolved into their full version 252 (instead of the reduced version usually returned with scores). 253 """ 254 255 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 256 257 # Resolve the assignment query. 258 matched_assignments = self.courses_assignments_get(resolved_course_query, [assignment_query], **kwargs) 259 if (len(matched_assignments) == 0): 260 return [] 261 262 target_assignment = matched_assignments[0] 263 264 # List the scores. 265 scores = self.courses_assignments_scores_list(resolved_course_query.get_id(), target_assignment.id, **kwargs) 266 if (len(scores) == 0): 267 return [] 268 269 # Resolve the scores' queries. 270 271 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 272 users_map = {user.id: user for user in users} 273 274 for score in scores: 275 score.assignment = target_assignment.to_query() 276 277 if ((score.user is not None) and (score.user.id in users_map)): 278 score.user = users_map[score.user.id].to_query() 279 280 return sorted(scores)
List the scores associated with the given assignment query. In addition to resolving the assignment query, users will also be resolved into their full version (instead of the reduced version usually returned with scores).
282 def courses_assignments_scores_resolve_and_upload(self, 283 course_query: lms.model.courses.CourseQuery, 284 assignment_query: lms.model.assignments.AssignmentQuery, 285 scores: typing.Dict[lms.model.users.UserQuery, lms.model.scores.ScoreFragment], 286 **kwargs: typing.Any) -> int: 287 """ 288 Resolve queries and upload assignment scores (indexed by user query). 289 A None score (ScoreFragment.score) indicates that the score should be cleared. 290 Return the number of scores sent to the LMS. 291 """ 292 293 if (len(scores) == 0): 294 return 0 295 296 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 297 resolved_assignment_query = self.resolve_assignment_query(resolved_course_query.get_id(), assignment_query, **kwargs) 298 299 resolved_users = self.resolve_user_queries(resolved_course_query.get_id(), list(scores.keys()), warn_on_miss = True) 300 resolved_scores: typing.Dict[str, lms.model.scores.ScoreFragment] = {} 301 302 for (user, score) in scores.items(): 303 for resolved_user in resolved_users: 304 if (user.match(resolved_user)): 305 resolved_scores[resolved_user.get_id()] = score 306 continue 307 308 if (len(resolved_scores) == 0): 309 return 0 310 311 return self.courses_assignments_scores_upload( 312 resolved_course_query.get_id(), 313 resolved_assignment_query.get_id(), 314 resolved_scores, 315 **kwargs)
Resolve queries and upload assignment scores (indexed by user query). A None score (ScoreFragment.score) indicates that the score should be cleared. Return the number of scores sent to the LMS.
317 def courses_assignments_scores_upload(self, 318 course_id: str, 319 assignment_id: str, 320 scores: typing.Dict[str, lms.model.scores.ScoreFragment], 321 **kwargs: typing.Any) -> int: 322 """ 323 Upload assignment scores (indexed by user id). 324 A None score (ScoreFragment.score) indicates that the score should be cleared. 325 Return the number of scores sent to the LMS. 326 """ 327 328 raise NotImplementedError('courses_assignments_scores_upload')
Upload assignment scores (indexed by user id). A None score (ScoreFragment.score) indicates that the score should be cleared. Return the number of scores sent to the LMS.
330 def courses_gradebook_get(self, 331 course_query: lms.model.courses.CourseQuery, 332 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 333 user_queries: typing.Collection[lms.model.users.UserQuery], 334 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 335 """ 336 Get a gradebook with the specified users and assignments. 337 Specifying no users/assignments is the same as requesting all of them. 338 """ 339 340 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 341 342 resolved_assignment_queries = self.resolve_assignment_queries(resolved_course_query.get_id(), assignment_queries, empty_all = True, **kwargs) 343 assignment_ids = [query.get_id() for query in resolved_assignment_queries] 344 345 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, 346 empty_all = True, only_students = True, **kwargs) 347 user_ids = [query.get_id() for query in resolved_user_queries] 348 349 gradebook = self.courses_gradebook_fetch(resolved_course_query.get_id(), assignment_ids, user_ids, **kwargs) 350 351 # Resolve the gradebook's queries (so it can show names/emails instead of just IDs). 352 gradebook.update_queries(resolved_assignment_queries, resolved_user_queries) 353 354 return gradebook
Get a gradebook with the specified users and assignments. Specifying no users/assignments is the same as requesting all of them.
356 def courses_gradebook_fetch(self, 357 course_id: str, 358 assignment_ids: typing.Collection[str], 359 user_ids: typing.Collection[str], 360 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 361 """ 362 Get a gradebook with the specified users and assignments. 363 If either the assignments or users is empty, an empty gradebook will be returned. 364 """ 365 366 raise NotImplementedError('courses_gradebook_fetch')
Get a gradebook with the specified users and assignments. If either the assignments or users is empty, an empty gradebook will be returned.
368 def courses_gradebook_list(self, 369 course_id: str, 370 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 371 """ 372 List the full gradebook associated with this course. 373 """ 374 375 return self.courses_gradebook_get(lms.model.courses.CourseQuery(id = course_id), [], [], **kwargs)
List the full gradebook associated with this course.
377 def courses_gradebook_resolve_and_list(self, 378 course_query: lms.model.courses.CourseQuery, 379 **kwargs: typing.Any) -> lms.model.scores.Gradebook: 380 """ 381 List the full gradebook associated with this course. 382 """ 383 384 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 385 return self.courses_gradebook_list(resolved_course_query.get_id(), **kwargs)
List the full gradebook associated with this course.
387 def courses_gradebook_resolve_and_upload(self, 388 course_query: lms.model.courses.CourseQuery, 389 gradebook: lms.model.scores.Gradebook, 390 **kwargs: typing.Any) -> int: 391 """ 392 Resolve queries and upload a gradebook. 393 Missing scores in the gradebook are skipped, 394 a None score (ScoreFragment.score) indicates that the score should be cleared. 395 Return the number of scores sent to the LMS. 396 """ 397 398 if (len(gradebook) == 0): 399 return 0 400 401 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 402 403 assignments = self.courses_assignments_list(resolved_course_query.get_id(), **kwargs) 404 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 405 406 resolved_assignment_queries = [assignment.to_query() for assignment in assignments] 407 resolved_user_queries = [user.to_query() for user in users] 408 409 gradebook.update_queries(resolved_assignment_queries, resolved_user_queries) 410 411 return self.courses_gradebook_upload( 412 resolved_course_query.get_id(), 413 gradebook, 414 **kwargs)
Resolve queries and upload a gradebook. Missing scores in the gradebook are skipped, a None score (ScoreFragment.score) indicates that the score should be cleared. Return the number of scores sent to the LMS.
416 def courses_gradebook_upload(self, 417 course_id: str, 418 gradebook: lms.model.scores.Gradebook, 419 **kwargs: typing.Any) -> int: 420 """ 421 Upload a gradebook. 422 All queries in the gradebook must be resolved (or at least have an ID). 423 Missing scores in the gradebook are skipped, 424 a None score (ScoreFragment.score) indicates that the score should be cleared. 425 Return the number of scores sent to the LMS. 426 """ 427 428 assignment_scores = gradebook.get_scores_by_assignment() 429 430 count = 0 431 for (assignment, user_scores) in assignment_scores.items(): 432 if (assignment.id is None): 433 raise ValueError(f"Assignment query for gradebook upload ({assignment}) does not have an ID.") 434 435 upload_scores = {} 436 for (user, score) in user_scores.items(): 437 if (user.id is None): 438 raise ValueError(f"User query for gradebook upload ({user}) does not have an ID.") 439 440 upload_scores[user.id] = score.to_fragment() 441 442 count += self.courses_assignments_scores_upload(course_id, assignment.id, upload_scores, **kwargs) 443 444 return count
Upload a gradebook. All queries in the gradebook must be resolved (or at least have an ID). Missing scores in the gradebook are skipped, a None score (ScoreFragment.score) indicates that the score should be cleared. Return the number of scores sent to the LMS.
446 def courses_groupsets_create(self, 447 course_id: str, 448 name: str, 449 **kwargs: typing.Any) -> lms.model.groupsets.GroupSet: 450 """ 451 Create a group set. 452 """ 453 454 raise NotImplementedError('courses_groupsets_create')
Create a group set.
456 def courses_groupsets_resolve_and_create(self, 457 course_query: lms.model.courses.CourseQuery, 458 name: str, 459 **kwargs: typing.Any) -> lms.model.groupsets.GroupSet: 460 """ 461 Resolve references and create a group set. 462 """ 463 464 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 465 return self.courses_groupsets_create(resolved_course_query.get_id(), name, **kwargs)
Resolve references and create a group set.
467 def courses_groupsets_delete(self, 468 course_id: str, 469 groupset_id: str, 470 **kwargs: typing.Any) -> bool: 471 """ 472 Delete a group set. 473 """ 474 475 raise NotImplementedError('courses_groupsets_delete')
Delete a group set.
477 def courses_groupsets_resolve_and_delete(self, 478 course_query: lms.model.courses.CourseQuery, 479 groupset_query: lms.model.groupsets.GroupSetQuery, 480 **kwargs: typing.Any) -> bool: 481 """ 482 Resolve references and create a group set. 483 """ 484 485 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 486 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 487 return self.courses_groupsets_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs)
Resolve references and create a group set.
489 def courses_groupsets_get(self, 490 course_query: lms.model.courses.CourseQuery, 491 groupset_queries: typing.Collection[lms.model.groupsets.GroupSetQuery], 492 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 493 """ 494 Get the specified group sets associated with the given course. 495 """ 496 497 if (len(groupset_queries) == 0): 498 return [] 499 500 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 501 groupset_queries = sorted(groupset_queries) 502 groupsets = sorted(self.courses_groupsets_list(resolved_course_query.get_id(), **kwargs)) 503 504 matches = [] 505 for groupset in groupsets: 506 for query in groupset_queries: 507 if (query.match(groupset)): 508 matches.append(groupset) 509 break 510 511 return matches
Get the specified group sets associated with the given course.
513 def courses_groupsets_fetch(self, 514 course_id: str, 515 groupset_id: str, 516 **kwargs: typing.Any) -> typing.Union[lms.model.groupsets.GroupSet, None]: 517 """ 518 Fetch a single group set associated with the given course. 519 Return None if no matching group set is found. 520 521 By default, this will just do a list and choose the relevant record. 522 Specific backends may override this if there are performance concerns. 523 """ 524 525 groupsets = self.courses_groupsets_list(course_id, **kwargs) 526 for groupset in groupsets: 527 if (groupset.id == groupset_id): 528 return groupset 529 530 return None
Fetch a single group set associated with the given course. Return None if no matching group set is found.
By default, this will just do a list and choose the relevant record. Specific backends may override this if there are performance concerns.
532 def courses_groupsets_list(self, 533 course_id: str, 534 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 535 """ 536 List the group sets associated with the given course. 537 """ 538 539 raise NotImplementedError('courses_groupsets_list')
List the group sets associated with the given course.
541 def courses_groupsets_resolve_and_list(self, 542 course_query: lms.model.courses.CourseQuery, 543 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSet]: 544 """ 545 List the group sets associated with the given course. 546 """ 547 548 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 549 return sorted(self.courses_groupsets_list(resolved_course_query.get_id(), **kwargs))
List the group sets associated with the given course.
551 def courses_groupsets_memberships_resolve_and_add(self, 552 course_query: lms.model.courses.CourseQuery, 553 groupset_query: lms.model.groupsets.GroupSetQuery, 554 memberships: typing.Collection[lms.model.groups.GroupMembership], 555 **kwargs: typing.Any) -> typing.Tuple[ 556 typing.List[lms.model.groups.Group], 557 typing.Dict[lms.model.groups.ResolvedGroupQuery, int] 558 ]: 559 """ 560 Resolve queries and add the specified users to the specified groups. 561 This may create groups. 562 563 Return: 564 - Created Groups 565 - Group Addition Counts 566 """ 567 568 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 569 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 570 571 found_group_memberships, missing_group_memberships, _ = self._resolve_group_memberships( 572 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 573 574 # Create missing groups. 575 created_groups = [] 576 for name in sorted(missing_group_memberships.keys()): 577 group = self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 578 created_groups.append(group) 579 580 # Merge in new group with existing structure. 581 query = group.to_query() 582 if (query not in found_group_memberships): 583 found_group_memberships[query] = [] 584 585 found_group_memberships[query] += missing_group_memberships[name] 586 587 # Add memberships. 588 counts = {} 589 for resolved_group_query in sorted(found_group_memberships.keys()): 590 resolved_user_queries = found_group_memberships[resolved_group_query] 591 592 count = self.courses_groups_memberships_resolve_and_add( 593 resolved_course_query, resolved_groupset_query, resolved_group_query, 594 resolved_user_queries, 595 **kwargs) 596 597 counts[resolved_group_query] = count 598 599 return (created_groups, counts)
Resolve queries and add the specified users to the specified groups. This may create groups.
Return:
- Created Groups
- Group Addition Counts
601 def courses_groupsets_memberships_resolve_and_set(self, 602 course_query: lms.model.courses.CourseQuery, 603 groupset_query: lms.model.groupsets.GroupSetQuery, 604 memberships: typing.Collection[lms.model.groups.GroupMembership], 605 **kwargs: typing.Any) -> typing.Tuple[ 606 typing.List[lms.model.groups.Group], 607 typing.List[lms.model.groups.ResolvedGroupQuery], 608 typing.Dict[lms.model.groups.ResolvedGroupQuery, int], 609 typing.Dict[lms.model.groups.ResolvedGroupQuery, int], 610 ]: 611 """ 612 Resolve queries and set the specified group memberships. 613 This may create and delete groups. 614 615 Return: 616 - Created Groups 617 - Deleted Groups 618 - Group Addition Counts 619 - Group Subtraction Counts 620 """ 621 622 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 623 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 624 625 found_group_memberships, missing_group_memberships, unused_groups = self._resolve_group_memberships( 626 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 627 628 # Delete unused groups. 629 deleted_groups = [] 630 for group_query in sorted(unused_groups): 631 result = self.courses_groups_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query.get_id(), **kwargs) 632 if (result): 633 deleted_groups.append(group_query) 634 635 # Create missing groups. 636 created_groups = [] 637 for name in sorted(missing_group_memberships.keys()): 638 group = self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs) 639 created_groups.append(group) 640 641 # Merge in new group with existing structure. 642 query = group.to_query() 643 if (query not in found_group_memberships): 644 found_group_memberships[query] = [] 645 646 found_group_memberships[query] += missing_group_memberships[name] 647 648 # Set memberships. 649 add_counts = {} 650 sub_counts = {} 651 for resolved_group_query in sorted(found_group_memberships.keys()): 652 resolved_user_queries = found_group_memberships[resolved_group_query] 653 654 (add_count, sub_count, deleted) = self.courses_groups_memberships_resolve_and_set( 655 resolved_course_query, resolved_groupset_query, resolved_group_query, 656 resolved_user_queries, 657 delete_empty = True, 658 **kwargs) 659 660 if (deleted): 661 deleted_groups.append(resolved_group_query) 662 663 add_counts[resolved_group_query] = add_count 664 sub_counts[resolved_group_query] = sub_count 665 666 return (created_groups, deleted_groups, add_counts, sub_counts)
Resolve queries and set the specified group memberships. This may create and delete groups.
Return:
- Created Groups
- Deleted Groups
- Group Addition Counts
- Group Subtraction Counts
668 def courses_groupsets_memberships_resolve_and_subtract(self, 669 course_query: lms.model.courses.CourseQuery, 670 groupset_query: lms.model.groupsets.GroupSetQuery, 671 memberships: typing.Collection[lms.model.groups.GroupMembership], 672 **kwargs: typing.Any) -> typing.Dict[lms.model.groups.ResolvedGroupQuery, int]: 673 """ 674 Resolve queries and subtract the specified users to the specified groups. 675 This will not delete any groups. 676 677 Return: 678 - Group Subtraction Counts 679 """ 680 681 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 682 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 683 684 found_group_memberships, missing_group_memberships, _ = self._resolve_group_memberships( 685 resolved_course_query.get_id(), resolved_groupset_query.get_id(), memberships, **kwargs) 686 687 # Warn about missing groups. 688 for name in sorted(missing_group_memberships.keys()): 689 _logger.warning("Group does not exist: '%s'.", name) 690 691 # Subtract memberships. 692 counts = {} 693 for resolved_group_query in sorted(found_group_memberships.keys()): 694 resolved_user_queries = found_group_memberships[resolved_group_query] 695 696 (count, _) = self.courses_groups_memberships_resolve_and_subtract( 697 resolved_course_query, resolved_groupset_query, resolved_group_query, 698 resolved_user_queries, 699 delete_empty = False, 700 **kwargs) 701 702 counts[resolved_group_query] = count 703 704 return counts
Resolve queries and subtract the specified users to the specified groups. This will not delete any groups.
Return:
- Group Subtraction Counts
706 def courses_groupsets_memberships_list(self, 707 course_id: str, 708 groupset_id: str, 709 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 710 """ 711 List the membership of the group sets associated with the given course. 712 """ 713 714 raise NotImplementedError('courses_groupsets_memberships_list')
List the membership of the group sets associated with the given course.
716 def courses_groupsets_memberships_resolve_and_list(self, 717 course_query: lms.model.courses.CourseQuery, 718 groupset_query: lms.model.groupsets.GroupSetQuery, 719 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 720 """ 721 List the membership of the group sets associated with the given course. 722 """ 723 724 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 725 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 726 727 memberships = self.courses_groupsets_memberships_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 728 729 # Resolve memberships. 730 731 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 732 groups = self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 733 734 users_map = {user.id: user.to_query() for user in users} 735 groups_map = {group.id: group.to_query() for group in groups} 736 737 for membership in memberships: 738 membership.update_queries(resolved_groupset_query, users = users_map, groups = groups_map) 739 740 return sorted(memberships)
List the membership of the group sets associated with the given course.
742 def courses_groups_create(self, 743 course_id: str, 744 groupset_id: str, 745 name: str, 746 **kwargs: typing.Any) -> lms.model.groups.Group: 747 """ 748 Create a group. 749 """ 750 751 raise NotImplementedError('courses_groups_create')
Create a group.
753 def courses_groups_resolve_and_create(self, 754 course_query: lms.model.courses.CourseQuery, 755 groupset_query: lms.model.groupsets.GroupSetQuery, 756 name: str, 757 **kwargs: typing.Any) -> lms.model.groups.Group: 758 """ 759 Resolve references and create a group. 760 """ 761 762 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 763 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 764 return self.courses_groups_create(resolved_course_query.get_id(), resolved_groupset_query.get_id(), name, **kwargs)
Resolve references and create a group.
766 def courses_groups_delete(self, 767 course_id: str, 768 groupset_id: str, 769 group_id: str, 770 **kwargs: typing.Any) -> bool: 771 """ 772 Delete a group. 773 """ 774 775 raise NotImplementedError('courses_groups_delete')
Delete a group.
777 def courses_groups_resolve_and_delete(self, 778 course_query: lms.model.courses.CourseQuery, 779 groupset_query: lms.model.groupsets.GroupSetQuery, 780 group_query: lms.model.groups.GroupQuery, 781 **kwargs: typing.Any) -> bool: 782 """ 783 Resolve references and create a group. 784 """ 785 786 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 787 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 788 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 789 return self.courses_groups_delete(resolved_course_query.get_id(), resolved_groupset_query.get_id(), resolved_group_query.get_id(), **kwargs)
Resolve references and create a group.
791 def courses_groups_get(self, 792 course_query: lms.model.courses.CourseQuery, 793 groupset_query: lms.model.groupsets.GroupSetQuery, 794 group_queries: typing.Collection[lms.model.groups.GroupQuery], 795 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 796 """ 797 Get the specified groups associated with the given course. 798 """ 799 800 if (len(group_queries) == 0): 801 return [] 802 803 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 804 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 805 groups = self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs) 806 807 group_queries = sorted(group_queries) 808 groups = sorted(groups) 809 810 matches = [] 811 for group in groups: 812 for query in group_queries: 813 if (query.match(group)): 814 matches.append(group) 815 break 816 817 return matches
Get the specified groups associated with the given course.
819 def courses_groups_fetch(self, 820 course_id: str, 821 groupset_id: str, 822 group_id: str, 823 **kwargs: typing.Any) -> typing.Union[lms.model.groups.Group, None]: 824 """ 825 Fetch a single group associated with the given course. 826 Return None if no matching group is found. 827 828 By default, this will just do a list and choose the relevant record. 829 Specific backends may override this if there are performance concerns. 830 """ 831 832 groups = self.courses_groups_list(course_id, groupset_id, **kwargs) 833 for group in groups: 834 if (group.id == group_id): 835 return group 836 837 return None
Fetch a single group associated with the given course. Return None if no matching group is found.
By default, this will just do a list and choose the relevant record. Specific backends may override this if there are performance concerns.
839 def courses_groups_list(self, 840 course_id: str, 841 groupset_id: str, 842 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 843 """ 844 List the groups associated with the given course. 845 """ 846 847 raise NotImplementedError('courses_groups_list')
List the groups associated with the given course.
849 def courses_groups_resolve_and_list(self, 850 course_query: lms.model.courses.CourseQuery, 851 groupset_query: lms.model.groupsets.GroupSetQuery, 852 **kwargs: typing.Any) -> typing.List[lms.model.groups.Group]: 853 """ 854 List the groups associated with the given course. 855 """ 856 857 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 858 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 859 return self.courses_groups_list(resolved_course_query.get_id(), resolved_groupset_query.get_id(), **kwargs)
List the groups associated with the given course.
861 def courses_groups_memberships_add(self, 862 course_id: str, 863 groupset_id: str, 864 group_id: str, 865 user_ids: typing.Collection[str], 866 **kwargs: typing.Any) -> int: 867 """ 868 Add the specified users to the group. 869 """ 870 871 raise NotImplementedError('courses_groups_memberships_add')
Add the specified users to the group.
873 def courses_groups_memberships_resolve_and_add(self, 874 course_query: lms.model.courses.CourseQuery, 875 groupset_query: lms.model.groupsets.GroupSetQuery, 876 group_query: lms.model.groups.GroupQuery, 877 user_queries: typing.Collection[lms.model.users.UserQuery], 878 **kwargs: typing.Any) -> int: 879 """ 880 Resolve queries and add the specified users to the group. 881 """ 882 883 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 884 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 885 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 886 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 887 888 # Get users already in this group. 889 group_memberships = self.courses_groups_memberships_list( 890 resolved_course_query.get_id(), 891 resolved_groupset_query.get_id(), 892 resolved_group_query.get_id(), 893 **kwargs) 894 895 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 896 897 # Filter out users already in the group. 898 user_ids = [] 899 for query in sorted(resolved_user_queries): 900 if (query.get_id() in group_user_ids): 901 _logger.warning("User '%s' already in group '%s'.", query, resolved_group_query) 902 continue 903 904 user_ids.append(query.get_id()) 905 906 if (len(user_ids) == 0): 907 return 0 908 909 return self.courses_groups_memberships_add( 910 resolved_course_query.get_id(), 911 resolved_groupset_query.get_id(), 912 resolved_group_query.get_id(), 913 user_ids, 914 **kwargs)
Resolve queries and add the specified users to the group.
916 def courses_groups_memberships_list(self, 917 course_id: str, 918 groupset_id: str, 919 group_id: str, 920 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 921 """ 922 List the membership of the group associated with the given group set. 923 """ 924 925 raise NotImplementedError('courses_groups_memberships_list')
List the membership of the group associated with the given group set.
927 def courses_groups_memberships_resolve_and_list(self, 928 course_query: lms.model.courses.CourseQuery, 929 groupset_query: lms.model.groupsets.GroupSetQuery, 930 group_query: lms.model.groups.GroupQuery, 931 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.GroupSetMembership]: 932 """ 933 List the membership of the group associated with the given group set. 934 """ 935 936 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 937 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 938 939 groups = self.courses_groups_get(resolved_course_query, resolved_groupset_query, [group_query], **kwargs) 940 if (len(groups) == 0): 941 raise ValueError(f"Unable to find group: '{group_query}'.") 942 943 group = groups[0] 944 945 memberships = self.courses_groups_memberships_list( 946 resolved_course_query.get_id(), 947 resolved_groupset_query.get_id(), 948 group.id, 949 **kwargs) 950 951 # Resolve memberships. 952 953 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 954 users_map = {user.id: user.to_query() for user in users} 955 956 groups_map = {group.id: group.to_query()} 957 958 for membership in memberships: 959 membership.update_queries(resolved_groupset_query, users = users_map, groups = groups_map) 960 961 return sorted(memberships)
List the membership of the group associated with the given group set.
963 def courses_groups_memberships_resolve_and_set(self, 964 course_query: lms.model.courses.CourseQuery, 965 groupset_query: lms.model.groupsets.GroupSetQuery, 966 group_query: lms.model.groups.GroupQuery, 967 user_queries: typing.Collection[lms.model.users.UserQuery], 968 delete_empty: bool = False, 969 **kwargs: typing.Any) -> typing.Tuple[int, int, bool]: 970 """ 971 Resolve queries and set the specified users for the group. 972 This method can both add and subtract users from the group. 973 974 Returns: 975 - The count of users added. 976 - The count of users subtracted. 977 - If this group was deleted. 978 """ 979 980 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 981 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 982 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 983 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 984 985 # Get users already in this group. 986 group_memberships = self.courses_groups_memberships_list( 987 resolved_course_query.get_id(), 988 resolved_groupset_query.get_id(), 989 resolved_group_query.get_id(), 990 **kwargs) 991 992 group_user_queries = {membership.user for membership in group_memberships if membership.user is not None} 993 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 994 query_user_ids = {resolved_user_query.get_id() for resolved_user_query in resolved_user_queries} 995 996 # Collect users that need to be added. 997 add_user_ids = [] 998 for query_user_id in query_user_ids: 999 if (query_user_id not in group_user_ids): 1000 add_user_ids.append(query_user_id) 1001 1002 # Collect users that need to be subtracted. 1003 sub_user_queries = [] 1004 for group_user_query in group_user_queries: 1005 if (group_user_query not in resolved_user_queries): 1006 sub_user_queries.append(group_user_query) 1007 1008 # Update the group. 1009 1010 add_count = 0 1011 if (len(add_user_ids) > 0): 1012 add_count = self.courses_groups_memberships_add( 1013 resolved_course_query.get_id(), 1014 resolved_groupset_query.get_id(), 1015 resolved_group_query.get_id(), 1016 add_user_ids, 1017 **kwargs) 1018 1019 sub_count = 0 1020 deleted = False 1021 if (len(sub_user_queries) > 0): 1022 sub_count, deleted = self.courses_groups_memberships_resolve_and_subtract( 1023 resolved_course_query, 1024 resolved_groupset_query, 1025 resolved_group_query, 1026 sub_user_queries, 1027 delete_empty = delete_empty, 1028 **kwargs) 1029 1030 return add_count, sub_count, deleted
Resolve queries and set the specified users for the group. This method can both add and subtract users from the group.
Returns:
- The count of users added.
- The count of users subtracted.
- If this group was deleted.
1032 def courses_groups_memberships_subtract(self, 1033 course_id: str, 1034 groupset_id: str, 1035 group_id: str, 1036 user_ids: typing.Collection[str], 1037 **kwargs: typing.Any) -> int: 1038 """ 1039 Subtract the specified users from the group. 1040 """ 1041 1042 raise NotImplementedError('courses_groups_memberships_subtract')
Subtract the specified users from the group.
1044 def courses_groups_memberships_resolve_and_subtract(self, 1045 course_query: lms.model.courses.CourseQuery, 1046 groupset_query: lms.model.groupsets.GroupSetQuery, 1047 group_query: lms.model.groups.GroupQuery, 1048 user_queries: typing.Collection[lms.model.users.UserQuery], 1049 delete_empty: bool = False, 1050 **kwargs: typing.Any) -> typing.Tuple[int, bool]: 1051 """ 1052 Resolve queries and subtract the specified users from the group. 1053 Return: 1054 - The number of users deleted. 1055 - If this group was deleted. 1056 """ 1057 1058 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1059 resolved_groupset_query = self.resolve_groupset_query(resolved_course_query.get_id(), groupset_query, **kwargs) 1060 resolved_group_query = self.resolve_group_query(resolved_course_query.get_id(), resolved_groupset_query.get_id(), group_query, **kwargs) 1061 resolved_user_queries = self.resolve_user_queries(resolved_course_query.get_id(), user_queries, warn_on_miss = True, **kwargs) 1062 1063 # Get users already in this group. 1064 group_memberships = self.courses_groups_memberships_list( 1065 resolved_course_query.get_id(), 1066 resolved_groupset_query.get_id(), 1067 resolved_group_query.get_id(), 1068 **kwargs) 1069 1070 group_user_ids = {membership.user.id for membership in group_memberships if membership.user.id is not None} 1071 1072 # Filter out users not in the group. 1073 user_ids = [] 1074 for query in resolved_user_queries: 1075 if (query.get_id() not in group_user_ids): 1076 _logger.warning("User '%s' is not in group '%s'.", query, resolved_group_query) 1077 continue 1078 1079 user_ids.append(query.get_id()) 1080 1081 if (delete_empty and len(group_memberships) == 0): 1082 deleted = self.courses_groups_delete( 1083 resolved_course_query.get_id(), 1084 resolved_groupset_query.get_id(), 1085 resolved_group_query.get_id(), 1086 **kwargs) 1087 return 0, deleted 1088 1089 if (len(user_ids) == 0): 1090 return 0, False 1091 1092 count = self.courses_groups_memberships_subtract( 1093 resolved_course_query.get_id(), 1094 resolved_groupset_query.get_id(), 1095 resolved_group_query.get_id(), 1096 user_ids, 1097 **kwargs) 1098 1099 deleted = False 1100 if (delete_empty and (count == len(group_memberships))): 1101 deleted = self.courses_groups_delete( 1102 resolved_course_query.get_id(), 1103 resolved_groupset_query.get_id(), 1104 resolved_group_query.get_id(), 1105 **kwargs) 1106 1107 return count, deleted
Resolve queries and subtract the specified users from the group. Return: - The number of users deleted. - If this group was deleted.
1109 def courses_syllabus_fetch(self, 1110 course_id: str, 1111 **kwargs: typing.Any) -> typing.Union[str, None]: 1112 """ 1113 Get the syllabus for a course, or None if no syllabus exists. 1114 """ 1115 1116 raise NotImplementedError('courses_syllabus_fetch')
Get the syllabus for a course, or None if no syllabus exists.
1118 def courses_syllabus_get(self, 1119 course_query: lms.model.courses.CourseQuery, 1120 **kwargs: typing.Any) -> typing.Union[str, None]: 1121 """ 1122 Get the syllabus for a course query, or None if no syllabus exists. 1123 """ 1124 1125 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1126 1127 return self.courses_syllabus_fetch(resolved_course_query.get_id(), **kwargs)
Get the syllabus for a course query, or None if no syllabus exists.
1129 def courses_users_get(self, 1130 course_query: lms.model.courses.CourseQuery, 1131 user_queries: typing.Collection[lms.model.users.UserQuery], 1132 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1133 """ 1134 Get the specified users associated with the given course. 1135 """ 1136 1137 if (len(user_queries) == 0): 1138 return [] 1139 1140 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1141 users = self.courses_users_list(resolved_course_query.get_id(), **kwargs) 1142 1143 user_queries = sorted(user_queries) 1144 users = sorted(users) 1145 1146 matches = [] 1147 for user in users: 1148 for query in user_queries: 1149 if (query.match(user)): 1150 matches.append(user) 1151 break 1152 1153 return matches
Get the specified users associated with the given course.
1155 def courses_users_fetch(self, 1156 course_id: str, 1157 user_id: str, 1158 **kwargs: typing.Any) -> typing.Union[lms.model.users.CourseUser, None]: 1159 """ 1160 Fetch a single user associated with the given course. 1161 Return None if no matching user is found. 1162 1163 By default, this will just do a list and choose the relevant record. 1164 Specific backends may override this if there are performance concerns. 1165 """ 1166 1167 users = self.courses_users_list(course_id, **kwargs) 1168 for user in sorted(users): 1169 if (user.id == user_id): 1170 return user 1171 1172 return None
Fetch a single user associated with the given course. Return None if no matching user is found.
By default, this will just do a list and choose the relevant record. Specific backends may override this if there are performance concerns.
1174 def courses_users_list(self, 1175 course_id: str, 1176 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1177 """ 1178 List the users associated with the given course. 1179 """ 1180 1181 raise NotImplementedError('courses_users_list')
List the users associated with the given course.
1183 def courses_users_resolve_and_list(self, 1184 course_query: lms.model.courses.CourseQuery, 1185 **kwargs: typing.Any) -> typing.List[lms.model.users.CourseUser]: 1186 """ 1187 List the users associated with the given course. 1188 """ 1189 1190 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1191 return list(sorted(self.courses_users_list(resolved_course_query.get_id(), **kwargs)))
List the users associated with the given course.
1193 def courses_users_scores_get(self, 1194 course_query: lms.model.courses.CourseQuery, 1195 user_query: lms.model.users.UserQuery, 1196 assignment_queries: typing.Collection[lms.model.assignments.AssignmentQuery], 1197 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1198 """ 1199 Get the scores associated with the given user query and assignment queries. 1200 """ 1201 1202 if (len(assignment_queries) == 0): 1203 return [] 1204 1205 scores = self.courses_users_scores_resolve_and_list(course_query, user_query, **kwargs) 1206 1207 scores = sorted(scores) 1208 assignment_queries = sorted(assignment_queries) 1209 1210 matches = [] 1211 for score in scores: 1212 for assignment_query in assignment_queries: 1213 if (assignment_query.match(score.assignment)): 1214 matches.append(score) 1215 1216 return matches
Get the scores associated with the given user query and assignment queries.
1218 def courses_users_scores_fetch(self, 1219 course_id: str, 1220 user_id: str, 1221 assignment_id: str, 1222 **kwargs: typing.Any) -> typing.Union[lms.model.scores.AssignmentScore, None]: 1223 """ 1224 Fetch the score associated with the given user and assignment. 1225 1226 By default, this will just do a list and choose the relevant record. 1227 Specific backends may override this if there are performance concerns. 1228 """ 1229 1230 # The default implementation is the same as courses_assignments_scores_fetch(). 1231 return self.courses_assignments_scores_fetch(course_id, assignment_id, user_id, **kwargs)
Fetch the score associated with the given user and assignment.
By default, this will just do a list and choose the relevant record. Specific backends may override this if there are performance concerns.
1233 def courses_users_scores_list(self, 1234 course_id: str, 1235 user_id: str, 1236 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1237 """ 1238 List the scores associated with the given user. 1239 """ 1240 1241 raise NotImplementedError('courses_users_scores_list')
List the scores associated with the given user.
1243 def courses_users_scores_resolve_and_list(self, 1244 course_query: lms.model.courses.CourseQuery, 1245 user_query: lms.model.users.UserQuery, 1246 **kwargs: typing.Any) -> typing.List[lms.model.scores.AssignmentScore]: 1247 """ 1248 List the scores associated with the given user query. 1249 In addition to resolving the user query, 1250 assignments will also be resolved into their full version 1251 (instead of the reduced version usually returned with scores). 1252 """ 1253 1254 resolved_course_query = self.resolve_course_query(course_query, **kwargs) 1255 1256 # Resolve the user query. 1257 matched_users = self.courses_users_get(resolved_course_query, [user_query], **kwargs) 1258 if (len(matched_users) == 0): 1259 return [] 1260 1261 target_user = matched_users[0] 1262 1263 # List the scores. 1264 scores = self.courses_users_scores_list(resolved_course_query.get_id(), target_user.id, **kwargs) 1265 if (len(scores) == 0): 1266 return [] 1267 1268 # Resolve the scores' queries. 1269 1270 assignments = self.courses_assignments_list(resolved_course_query.get_id(), **kwargs) 1271 assignments_map = {assignment.id: assignment for assignment in assignments} 1272 1273 for score in scores: 1274 score.user = target_user.to_query() 1275 1276 if ((score.assignment is not None) and (score.assignment.id in assignments_map)): 1277 score.assignment = assignments_map[score.assignment.id].to_query() 1278 1279 return sorted(scores)
List the scores associated with the given user query. In addition to resolving the user query, assignments will also be resolved into their full version (instead of the reduced version usually returned with scores).
1283 def parse_assignment_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.assignments.AssignmentQuery, None]: 1284 """ 1285 Attempt to parse an assignment query from a string. 1286 The there is no query, return a None. 1287 If the query is malformed, raise an exception. 1288 1289 By default, this method assumes that LMS IDs are ints. 1290 Child backends may override this to implement their specific behavior. 1291 """ 1292 1293 return lms.model.query.parse_int_query(lms.model.assignments.AssignmentQuery, text, check_email = False)
Attempt to parse an assignment query from a string. The there is no query, return a None. If the query is malformed, raise an exception.
By default, this method assumes that LMS IDs are ints. Child backends may override this to implement their specific behavior.
1295 def parse_assignment_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.assignments.AssignmentQuery]: 1296 """ Parse a list of assignment queries. """ 1297 1298 queries = [] 1299 for text in texts: 1300 query = self.parse_assignment_query(text) 1301 if (query is not None): 1302 queries.append(query) 1303 1304 return queries
Parse a list of assignment queries.
1306 def parse_course_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.courses.CourseQuery, None]: 1307 """ 1308 Attempt to parse a course query from a string. 1309 The there is no query, return a None. 1310 If the query is malformed, raise an exception. 1311 1312 By default, this method assumes that LMS IDs are ints. 1313 Child backends may override this to implement their specific behavior. 1314 """ 1315 1316 return lms.model.query.parse_int_query(lms.model.courses.CourseQuery, text, check_email = False)
Attempt to parse a course query from a string. The there is no query, return a None. If the query is malformed, raise an exception.
By default, this method assumes that LMS IDs are ints. Child backends may override this to implement their specific behavior.
1318 def parse_course_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.courses.CourseQuery]: 1319 """ Parse a list of course queries. """ 1320 1321 queries = [] 1322 for text in texts: 1323 query = self.parse_course_query(text) 1324 if (query is not None): 1325 queries.append(query) 1326 1327 return queries
Parse a list of course queries.
1329 def parse_groupset_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.groupsets.GroupSetQuery, None]: 1330 """ 1331 Attempt to parse a group set query from a string. 1332 The there is no query, return a None. 1333 If the query is malformed, raise an exception. 1334 1335 By default, this method assumes that LMS IDs are ints. 1336 Child backends may override this to implement their specific behavior. 1337 """ 1338 1339 return lms.model.query.parse_int_query(lms.model.groupsets.GroupSetQuery, text, check_email = False)
Attempt to parse a group set query from a string. The there is no query, return a None. If the query is malformed, raise an exception.
By default, this method assumes that LMS IDs are ints. Child backends may override this to implement their specific behavior.
1341 def parse_groupset_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.groupsets.GroupSetQuery]: 1342 """ Parse a list of group set queries. """ 1343 1344 queries = [] 1345 for text in texts: 1346 query = self.parse_groupset_query(text) 1347 if (query is not None): 1348 queries.append(query) 1349 1350 return queries
Parse a list of group set queries.
1352 def parse_group_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.groups.GroupQuery, None]: 1353 """ 1354 Attempt to parse a group query from a string. 1355 The there is no query, return a None. 1356 If the query is malformed, raise an exception. 1357 1358 By default, this method assumes that LMS IDs are ints. 1359 Child backends may override this to implement their specific behavior. 1360 """ 1361 1362 return lms.model.query.parse_int_query(lms.model.groups.GroupQuery, text, check_email = False)
Attempt to parse a group query from a string. The there is no query, return a None. If the query is malformed, raise an exception.
By default, this method assumes that LMS IDs are ints. Child backends may override this to implement their specific behavior.
1364 def parse_group_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.groups.GroupQuery]: 1365 """ Parse a list of group queries. """ 1366 1367 queries = [] 1368 for text in texts: 1369 query = self.parse_group_query(text) 1370 if (query is not None): 1371 queries.append(query) 1372 1373 return queries
Parse a list of group queries.
1375 def parse_user_query(self, text: typing.Union[str, None]) -> typing.Union[lms.model.users.UserQuery, None]: 1376 """ 1377 Attempt to parse a user query from a string. 1378 The there is no query, return a None. 1379 If the query is malformed, raise an exception. 1380 1381 By default, this method assumes that LMS IDs are ints. 1382 Child backends may override this to implement their specific behavior. 1383 """ 1384 1385 return lms.model.query.parse_int_query(lms.model.users.UserQuery, text, check_email = True)
Attempt to parse a user query from a string. The there is no query, return a None. If the query is malformed, raise an exception.
By default, this method assumes that LMS IDs are ints. Child backends may override this to implement their specific behavior.
1387 def parse_user_queries(self, texts: typing.Collection[typing.Union[str, None]]) -> typing.List[lms.model.users.UserQuery]: 1388 """ Parse a list of user queries. """ 1389 1390 queries = [] 1391 for text in texts: 1392 query = self.parse_user_query(text) 1393 if (query is not None): 1394 queries.append(query) 1395 1396 return queries
Parse a list of user queries.
1398 def resolve_assignment_query(self, 1399 course_id: str, 1400 assignment_query: lms.model.assignments.AssignmentQuery, 1401 **kwargs: typing.Any) -> lms.model.assignments.ResolvedAssignmentQuery: 1402 """ Resolve the assignment query or raise an exception. """ 1403 1404 # Shortcut already resolved queries. 1405 if (isinstance(assignment_query, lms.model.assignments.ResolvedAssignmentQuery)): 1406 return assignment_query 1407 1408 results = self.resolve_assignment_queries(course_id, [assignment_query], **kwargs) 1409 if (len(results) == 0): 1410 raise ValueError(f"Could not resolve assignment query: '{assignment_query}'.") 1411 1412 return results[0]
Resolve the assignment query or raise an exception.
1414 def resolve_assignment_queries(self, 1415 course_id: str, 1416 queries: typing.Collection[lms.model.assignments.AssignmentQuery], 1417 **kwargs: typing.Any) -> typing.List[lms.model.assignments.ResolvedAssignmentQuery]: 1418 """ 1419 Resolve a list of assignment queries into a list of resolved assignment queries. 1420 See _resolve_queries(). 1421 """ 1422 1423 results = self._resolve_queries( 1424 queries, 1425 'assignment', 1426 self.courses_assignments_list(course_id, **kwargs), 1427 lms.model.assignments.ResolvedAssignmentQuery, 1428 **kwargs) 1429 1430 return typing.cast(typing.List[lms.model.assignments.ResolvedAssignmentQuery], results)
Resolve a list of assignment queries into a list of resolved assignment queries. See _resolve_queries().
1432 def resolve_course_query(self, 1433 query: lms.model.courses.CourseQuery, 1434 **kwargs: typing.Any) -> lms.model.courses.ResolvedCourseQuery: 1435 """ Resolve the course query or raise an exception. """ 1436 1437 # Shortcut already resolved queries. 1438 if (isinstance(query, lms.model.courses.ResolvedCourseQuery)): 1439 return query 1440 1441 results = self.resolve_course_queries([query], **kwargs) 1442 if (len(results) == 0): 1443 raise ValueError(f"Could not resolve course query: '{query}'.") 1444 1445 return results[0]
Resolve the course query or raise an exception.
1447 def resolve_course_queries(self, 1448 queries: typing.Collection[lms.model.courses.CourseQuery], 1449 **kwargs: typing.Any) -> typing.List[lms.model.courses.ResolvedCourseQuery]: 1450 """ 1451 Resolve a list of course queries into a list of resolved course queries. 1452 See _resolve_queries(). 1453 """ 1454 1455 results = self._resolve_queries( 1456 queries, 1457 'course', 1458 self.courses_list(**kwargs), 1459 lms.model.courses.ResolvedCourseQuery, 1460 **kwargs) 1461 1462 return typing.cast(typing.List[lms.model.courses.ResolvedCourseQuery], results)
Resolve a list of course queries into a list of resolved course queries. See _resolve_queries().
1464 def resolve_group_queries(self, 1465 course_id: str, 1466 groupset_id: str, 1467 queries: typing.Collection[lms.model.groups.GroupQuery], 1468 **kwargs: typing.Any) -> typing.List[lms.model.groups.ResolvedGroupQuery]: 1469 """ 1470 Resolve a list of group queries into a list of resolved group queries. 1471 See _resolve_queries(). 1472 """ 1473 1474 results = self._resolve_queries( 1475 queries, 1476 'group', 1477 self.courses_groups_list(course_id, groupset_id, **kwargs), 1478 lms.model.groups.ResolvedGroupQuery, 1479 **kwargs) 1480 1481 return typing.cast(typing.List[lms.model.groups.ResolvedGroupQuery], results)
Resolve a list of group queries into a list of resolved group queries. See _resolve_queries().
1483 def resolve_group_query(self, 1484 course_id: str, 1485 groupset_id: str, 1486 query: lms.model.groups.GroupQuery, 1487 **kwargs: typing.Any) -> lms.model.groups.ResolvedGroupQuery: 1488 """ Resolve the group query or raise an exception. """ 1489 1490 # Shortcut already resolved queries. 1491 if (isinstance(query, lms.model.groups.ResolvedGroupQuery)): 1492 return query 1493 1494 results = self.resolve_group_queries(course_id, groupset_id, [query], **kwargs) 1495 if (len(results) == 0): 1496 raise ValueError(f"Could not resolve group query: '{query}'.") 1497 1498 return results[0]
Resolve the group query or raise an exception.
1500 def resolve_groupset_queries(self, 1501 course_id: str, 1502 queries: typing.Collection[lms.model.groupsets.GroupSetQuery], 1503 **kwargs: typing.Any) -> typing.List[lms.model.groupsets.ResolvedGroupSetQuery]: 1504 """ 1505 Resolve a list of group set queries into a list of resolved group set queries. 1506 See _resolve_queries(). 1507 """ 1508 1509 results = self._resolve_queries( 1510 queries, 1511 'group set', 1512 self.courses_groupsets_list(course_id, **kwargs), 1513 lms.model.groupsets.ResolvedGroupSetQuery, 1514 **kwargs) 1515 1516 return typing.cast(typing.List[lms.model.groupsets.ResolvedGroupSetQuery], results)
Resolve a list of group set queries into a list of resolved group set queries. See _resolve_queries().
1518 def resolve_groupset_query(self, 1519 course_id: str, 1520 groupset_query: lms.model.groupsets.GroupSetQuery, 1521 **kwargs: typing.Any) -> lms.model.groupsets.ResolvedGroupSetQuery: 1522 """ Resolve the group set query or raise an exception. """ 1523 1524 # Shortcut already resolved queries. 1525 if (isinstance(groupset_query, lms.model.groupsets.ResolvedGroupSetQuery)): 1526 return groupset_query 1527 1528 results = self.resolve_groupset_queries(course_id, [groupset_query], **kwargs) 1529 if (len(results) == 0): 1530 raise ValueError(f"Could not resolve group set query: '{groupset_query}'.") 1531 1532 return results[0]
Resolve the group set query or raise an exception.
1534 def resolve_user_queries(self, 1535 course_id: str, 1536 queries: typing.Collection[lms.model.users.UserQuery], 1537 only_students: bool = False, 1538 **kwargs: typing.Any) -> typing.List[lms.model.users.ResolvedUserQuery]: 1539 """ 1540 Resolve a list of user queries into a list of resolved user queries. 1541 See _resolve_queries(). 1542 """ 1543 1544 filter_func = None 1545 if (only_students): 1546 filter_func = lambda user: user.is_student() # pylint: disable=unnecessary-lambda-assignment 1547 1548 results = self._resolve_queries( 1549 queries, 1550 'user', 1551 self.courses_users_list(course_id, **kwargs), 1552 lms.model.users.ResolvedUserQuery, 1553 filter_func = filter_func, 1554 **kwargs) 1555 1556 return typing.cast(typing.List[lms.model.users.ResolvedUserQuery], results)
Resolve a list of user queries into a list of resolved user queries. See _resolve_queries().