diff --git a/api/canvas_classes.py b/api/canvas_classes.py index c05ab63ecfb05140310f5657030ca62feadcffcf..69b0de55f588b86dbb68e506bb666d4d99da2b31 100644 --- a/api/canvas_classes.py +++ b/api/canvas_classes.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Dict, Iterable, List, Union +from typing import ClassVar, Dict, Iterable, List, Optional, Union from canvasapi import Canvas from canvasapi.assignment import Assignment, AssignmentGroup @@ -14,11 +14,11 @@ from config import Config class CanvasSession: __instance: ClassVar[Canvas] = None - @staticmethod - def get_session() -> Canvas: - if CanvasSession.__instance is None: - CanvasSession.__instance = Canvas(Config.canvas_url, Config.canvas_api_key) - return CanvasSession.__instance + @classmethod + def get_session(cls) -> Canvas: + if cls.__instance is None: + cls.__instance = Canvas(Config.canvas_url, Config.canvas_api_key) + return cls.__instance # PEOPLE CLASSES @@ -75,6 +75,9 @@ class CanvasUser: def __ne__(self, other: "CanvasUser") -> bool: return not self.__eq__(other) + def __hash__(self) -> int: + return hash(self.get_name()) + """ // A Canvas user, e.g. a student, teacher, administrator, observer, etc. @@ -306,7 +309,7 @@ class CanvasAssignment: def is_quiz(self) -> bool: return 'online_quiz' in self.canvas_assignment.submission_types - def get_submission_text(self, canvas_user: CanvasUser) -> Union[str, None]: + def get_submission_text(self, canvas_user: CanvasUser) -> Optional[str]: submission: Submission = self.canvas_assignment.get_submission(canvas_user.get_canvas_id()) if submission.submission_type == 'online_text_entry': return submission.body # TODO: what happens if there is no user submission? diff --git a/api/composite_user.py b/api/composite_user.py index 257114b23c27d029f9e1b6962b4c19da56fbd77f..7a380a5c957247fe93099cc8fa8f19d12c304091 100644 --- a/api/composite_user.py +++ b/api/composite_user.py @@ -1,5 +1,5 @@ import csv -from typing import ClassVar, Collection, Dict, Set, List +from typing import ClassVar, Collection, Dict, List, Set from api.canvas_classes import CanvasUser from api.canvas_classes import CanvasCourse @@ -42,9 +42,22 @@ class CompositeUser: CompositeUser.instances[self.canvas_username] = self CompositeUser.instances[self.gitlab_username] = self + @classmethod + def initialize_composite_user(cls, canvas_student: CanvasUser, gitlab_student: GitlabUser) -> "CompositeUser": + student: Dict[str, str] = {'SortableName': canvas_student.get_sortable_name(), + 'ReadableName': canvas_student.get_name(), + 'NUID': str(canvas_student.get_nuid()), + 'CanvasUsername': canvas_student.get_username(), + 'CanvasEmail': canvas_student.get_email(), + 'GitlabUsername': gitlab_student.get_username(), + 'GitlabEmail': gitlab_student.get_email()} + return CompositeUser(student, [], []) + def get_canvas_user(self) -> CanvasUser: if self.canvas_user is None: - self.canvas_user = CanvasUser(self.NUID) # n.b., can retrieve own user but not arbitrary user + # self.canvas_user = CanvasUser(self.NUID) # n.b., can retrieve own user but not arbitrary user + all_students: List[CanvasUser] = CanvasCourse(Course.canvas_course_id).get_students() + self.canvas_user = list(filter(lambda s: s.get_username() == self.canvas_username, all_students))[0] return self.canvas_user def get_gitlab_user(self) -> GitlabUser: @@ -52,13 +65,16 @@ class CompositeUser: self.gitlab_user = GitlabUser(self.gitlab_username) return self.gitlab_user + def set_gitlab_email(self, email: str) -> None: + self.gitlab_email = email + def tentatively_pair_with(self, username: str) -> None: self.candidate_teammates = {username} def tentatively_team_with(self, usernames: Collection[str]) -> None: self.candidate_teammates = set(usernames) - def commit_team(self) -> None: # TODO: Will want to re-visit this based on tracking by project + def commit_team(self) -> None: # TODO: Will want to re-visit this based on tracking by project self.graylist = self.graylist.union(self.candidate_teammates) def discard_team(self) -> None: @@ -94,9 +110,9 @@ class CompositeUser: def __hash__(self) -> int: return hash(self.canvas_username) - @staticmethod - def get_user(username_or_email: str) -> "CompositeUser": - return CompositeUser.instances[username_or_email] + @classmethod + def get_user(cls, username_or_email: str) -> "CompositeUser": + return cls.instances[username_or_email] # TODO: Will want to re-visit these based on tracking graylist by project... @staticmethod diff --git a/api/gitlab_classes.py b/api/gitlab_classes.py index f129c66840936e87d19ed77f29d830bd2862a5c9..90ab56b513dc2c5b7b6bca88551dc78b6a7127ff 100644 --- a/api/gitlab_classes.py +++ b/api/gitlab_classes.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import ClassVar, Dict, Iterable, List, Set, Union +from typing import ClassVar, Dict, Iterable, List, Optional, Set, Union from gitlab import Gitlab, MAINTAINER_ACCESS from gitlab.v4.objects import Issue, Project, ProjectCommit, User @@ -10,11 +10,11 @@ from config import Config class GitlabSession: __instance: ClassVar[Gitlab] = None - @staticmethod - def get_session() -> Gitlab: - if GitlabSession.__instance is None: - GitlabSession.__instance = Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key) - return GitlabSession.__instance + @classmethod + def get_session(cls) -> Gitlab: + if cls.__instance is None: + cls.__instance = Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key) + return cls.__instance class GitlabUser: @@ -43,6 +43,13 @@ class GitlabUser: def get_username(self) -> str: return self.git_user.username + def get_email(self) -> Optional[str]: + # return self.git_user.emails.list()[0] # list() is forbidden + try: + return self.git_user.email + except AttributeError: + return None + def get_site(self) -> str: return self.git_user.web_url @@ -119,7 +126,7 @@ class GitlabIssue: updated_at = updated_at[:-1] + '+00:00' return datetime.fromisoformat(updated_at) - def get_closed_at(self) -> Union[datetime, None]: + def get_closed_at(self) -> Optional[datetime]: """ :return: an "aware" datetime object representing the last date/time the issue was closed, or None if the issue is open diff --git a/common_functions.py b/common_functions.py new file mode 100644 index 0000000000000000000000000000000000000000..d902378881f3d5c2628b4300569563e260fac22d --- /dev/null +++ b/common_functions.py @@ -0,0 +1,33 @@ +import datetime +from typing import List, Optional, TypeVar + +ChoiceType = TypeVar("ChoiceType") + + +def select_from_list(choices: List[ChoiceType], choice_name: str) -> ChoiceType: + print(f'Choose the {choice_name} from this list:') + for i in range(len(choices)): + print(f'{i+1})\t{choices[i]}'.expandtabs(4)) + selection = input('Enter selection: ') + return choices[int(selection) - 1] + + +def strip_html(text: Optional[str]) -> Optional[str]: + import re + if text is not None: + return re.sub(re.compile('<.*?>'), '', text) + else: + return None + +def semester_stamp() -> str: + today = datetime.date.today() + year: int = today.year + month: int = today.month + # Normalize month to semester start + if month < 5: + month = 1 + elif month < 8: + month = 5 + else: + month = 8 + return f'{year}-0{month}' diff --git a/grade_team_contribution.py b/grade_team_contribution.py index 6f522aaba965625dea87d4c74f90aa51332d10e7..95cbbdb6c652ad0ee1a5eea39387ef22be2a0cd5 100644 --- a/grade_team_contribution.py +++ b/grade_team_contribution.py @@ -1,21 +1,14 @@ import textwrap +from typing import List from api.canvas_classes import * from api.gitlab_classes import * +from common_functions import select_from_list, strip_html from course import Course -def structure_text(text): - import re - return textwrap.wrap(re.sub(re.compile('<.*?>'), '', text)) - - -def select_from_list(choices, choice_name): - print(f'Choose the {choice_name} from this list:') - for i in range(len(choices)): - print(f'{i+1})\t{choices[i]}'.expandtabs(4)) - selection = input('Enter selection: ') - return choices[int(selection)-1] +def structure_text(text: str) -> List[str]: + return textwrap.wrap(strip_html(text)) def display_peer_reviews(assignment, students): diff --git a/initialize_student_tracking.py b/initialize_student_tracking.py new file mode 100644 index 0000000000000000000000000000000000000000..2a5bfc77e02354765db34b8e6f38326a0892fef9 --- /dev/null +++ b/initialize_student_tracking.py @@ -0,0 +1,187 @@ +import datetime +import subprocess +from typing import Dict, List, Optional, Set + +from gitlab.exceptions import GitlabGetError, GitlabListError + +from api.canvas_classes import CanvasCourse, CanvasAssignment, CanvasUser +from api.composite_user import CompositeUser +from api.gitlab_classes import GitlabUser, GitlabProject, GitlabCommit +from common_functions import select_from_list, strip_html, semester_stamp +from course import Course + +DEFAULT_HOMEWORK_REPOSITORY = 'csce361-homework' + + +def get_responses(assignment: CanvasAssignment, students: List[CanvasUser]) -> Dict[CanvasUser, str]: + # TODO: Can this be made more general and be useful to grade_team_contribution.display_peer_reviews() ? + student_responses: Dict[CanvasUser, Optional[str]] = {} + # noinspection PyUnusedLocal + student: CanvasUser + for student in students: + student_responses[student] = strip_html(assignment.get_submission_text(student)) + return student_responses + + +def extract_gitlab_usernames(student_responses: Dict[CanvasUser, str]) -> Dict[CanvasUser, str]: + student_gitlab_usernames: Dict[CanvasUser, str] = {} + # noinspection PyUnusedLocal + student: CanvasUser + for student in student_responses: + response: str = student_responses[student] + if response is None: + response = '' + candidate_username: List[str] = response.split() + if len(candidate_username) == 1: + username: str = candidate_username[0] + if username[0] == '@': + student_gitlab_usernames[student] = username[1:] + else: + student_gitlab_usernames[student] = username + elif len(candidate_username) > 1: + print(f'Unexpected response from {student.get_name()}: {response}') + potential_usernames = list(filter(lambda s: s[0] == '@', candidate_username)) + if len(potential_usernames) == 1: + username: str = potential_usernames[0] + print(f'Assigning {username} as username.') + student_gitlab_usernames[student] = username[1:] + else: + username: str = input('Enter gitlab username: ') + student_gitlab_usernames[student] = username + else: + print(f'{student.get_name()} has no response; assigning blank gitlab username.') + student_gitlab_usernames[student] = '' + print(f'\tCanvas username: {student.get_username()}\tGitlab username: @{student_gitlab_usernames[student]}') + return student_gitlab_usernames + + +def retrieve_gitlab_user(gitlab_username: str, student_name: str) -> Optional[GitlabUser]: + try: + if gitlab_username != '': + return GitlabUser(gitlab_username) + else: + return None + except IndexError: + print(f'\t{student_name} has an invalid gitlab username: {gitlab_username}.') + new_username: str = input('\tEnter new gitlab username (enter blank to skip): ') + return retrieve_gitlab_user(new_username, student_name) + + +# noinspection PyShadowingNames +def create_composite_users(student_usernames: Dict[CanvasUser, str]) -> (Set[CompositeUser], Set[CanvasUser]): + composite_users: Set[CompositeUser] = set() + skipped_users: Set[CanvasUser] = set() + # noinspection PyUnusedLocal + canvas_student: CanvasUser + for canvas_student in student_usernames: + gitlab_username: str = student_usernames[canvas_student] + gitlab_student: GitlabUser = retrieve_gitlab_user(gitlab_username, canvas_student.get_name()) + if gitlab_student is not None: + composite_user: CompositeUser = CompositeUser.initialize_composite_user(canvas_student, gitlab_student) + composite_users.add(composite_user) + else: + print(f'{canvas_student.get_name()} has a blank gitlab username; skipping.') + skipped_users.add(canvas_student) + return composite_users, skipped_users + + +def retrieve_homework_repos(students: Set[CompositeUser], repo_name: str) \ + -> Dict[CompositeUser, Optional[GitlabProject]]: + user_repos: Dict[CompositeUser, GitlabProject] = {} + for student in students: + path = student.get_gitlab_user().get_username() + '/' + repo_name + try: + print(f'\tRetrieving {path}.git at {datetime.datetime.now()}.') + repo = GitlabProject(path) + except GitlabGetError: + print(f'*** WARNING *** Could not retrieve {path}.git!') + repo = None + user_repos[student] = repo + return user_repos + + +def add_gitlab_email(user_repos: Dict[CompositeUser, Optional[GitlabProject]]) -> None: + # noinspection PyUnusedLocal + student: CompositeUser + for student in user_repos: + project = user_repos[student] + if project is not None: + try: + commits: List[GitlabCommit] = project.get_commits() + except GitlabListError: + print(f'*** NOTE *** Could not retrieve gitlab email for {student.get_canvas_user().get_name()}. ' + f'This probably indicates Guest permissions.') + commits = [] + if len(commits) > 0: + commit = commits[-1] # should be the initial commit; regardless, all commits should be by the student + email = commit.get_author()['email'] + student.set_gitlab_email(email) + + +def create_cloning_script(user_repos: Dict[CompositeUser, Optional[GitlabProject]], + no_username: Set[CanvasUser]) -> None: + filename = f'{semester_stamp()}-homework.sh' + file = open(filename, 'x') + file.write('#!/bin/bash\n\n') + file.write('# Auto-generated clone script.\n') + no_repo: Set[CompositeUser] = set() + # noinspection PyUnusedLocal + composite_student: CompositeUser + for composite_student in user_repos: + repo = user_repos[composite_student] + if repo is not None: + repo_url = repo.get_cloning_url() + student_name = composite_student.get_canvas_user().get_name().replace(' ', '_') + file.write(f'git clone {repo_url} {student_name}') + if composite_student.gitlab_email is None: # breaking encapsulation? + file.write(' # may have inadequate permissions') + file.write('\n') + else: + no_repo.add(composite_student) + if len(no_repo) > 0: + file.write('\n# Students without homework repositories ' + '(check for misspelled repository names or repositories created but not shared):\n') + for composite_student in no_repo: + student_name = composite_student.get_canvas_user().get_name().replace(' ', '_') + file.write(f'# git clone {composite_student.get_gitlab_user().get_username()}/... {student_name}\n') + if len(no_username) > 0: + file.write('\n# Students without valid gitlab usernames (check for repositories shared but username not ' + 'submitted):\n') + # noinspection PyUnusedLocal + canvas_student: CanvasUser + for canvas_student in no_username: + student_name = canvas_student.get_name().replace(' ', '_') + file.write(f'# git clone ... {student_name}\n') + file.close() + subprocess.call(['chmod', '+x', filename]) + print(f'\tCreated {filename}. Distribute to TAs so they can clone the homework repositories.') + + +if __name__ == '__main__': + course = CanvasCourse(Course.canvas_course_id) + print('First, select the "setup" assignment to extract student information from.\n') + assignment_groups = course.get_assignment_groups() + assignment_group = select_from_list(assignment_groups, 'assignment group') + print() + assignments = assignment_group.get_assignments() + setup_assignment = select_from_list(assignments, 'assignment') + print(f'\nWe will now extract student information from {setup_assignment}.') + responses = get_responses(setup_assignment, course.get_students()) + gitlab_usernames = extract_gitlab_usernames(responses) + print('\nWe will now check the validity of the gitlab usernames.') + composite_users, skipped_users = create_composite_users(gitlab_usernames) + if len(skipped_users) == 0: + print(f'\nSuccessfully retrieved {len(composite_users)} students.') + else: + print( + f'\n*** WARNING *** Skipped {len(skipped_users)} ' + f'out of {len(skipped_users)+len(composite_users)} students!') + print('We will now retrieve the students\' homework repositories.') + choice = input(f'Enter homework repository name [{DEFAULT_HOMEWORK_REPOSITORY}]: ') + if choice == '': + choice = DEFAULT_HOMEWORK_REPOSITORY + homework_repos = retrieve_homework_repos(composite_users, choice) + add_gitlab_email(homework_repos) + print('\nCreating cloning script for homework repositories.') + create_cloning_script(homework_repos, skipped_users) + # create_csv(homework_repos)