diff --git a/api/gitlab_classes.py b/api/gitlab_classes.py index 804e7e7c541d2f2ca6988a0b269ebd4abb9c840b..8d213be4d11074beaa496fab75fe3581831d7a04 100644 --- a/api/gitlab_classes.py +++ b/api/gitlab_classes.py @@ -432,7 +432,6 @@ class GitlabProject: commits.append(GitlabCommit(commit)) return commits - def get_labels(self) -> Set[str]: """ :return: set of label names diff --git a/common_functions.py b/common_functions.py index 0c98e5fcbb478f4f9aa79a380dab00a746454193..79f374ebcb05c0752c32aa9c4374c16dddc3e375 100644 --- a/common_functions.py +++ b/common_functions.py @@ -4,12 +4,31 @@ 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 select_from_list(choices: List[ChoiceType], choice_name: str, none_is_option: bool = False) -> ChoiceType: + query_user = True + return_value: ChoiceType = None + while query_user: + print(f'Choose the {choice_name} from this list:') + number_of_choices = len(choices) + for i in range(number_of_choices): + print(f'{i+1})\t{choices[i]}'.expandtabs(4)) + if none_is_option: + print('0)\tNone of the above'.expandtabs(4)) + selection = input('Enter selection: ') + try: + selection_value = int(selection) + if none_is_option and selection_value == 0: + return_value = None + query_user = False + elif selection_value < 1 or selection_value > number_of_choices: + raise ValueError('Selection out of range.') + else: + return_value = choices[selection_value - 1] + query_user = False + except ValueError: + print(f'\tSelection must be an integer between {0 if none_is_option else 1} ' + f'and {number_of_choices}, inclusive!') + return return_value def strip_html(text: Optional[str]) -> Optional[str]: diff --git a/grade_team_contribution.py b/grade_team_contribution.py index c090ad18b0854cb9fec0fcfe2bd7880b6e4cd38d..1f7b5d97eafecfad13f4dc7e0fc09db8a6a74995 100644 --- a/grade_team_contribution.py +++ b/grade_team_contribution.py @@ -1,4 +1,5 @@ import textwrap +from datetime import date from math import ceil, log10 from typing import Tuple @@ -7,6 +8,58 @@ from api.gitlab_classes import * from common_functions import select_from_list, strip_html from course import Course +assignment_start_date: str = '' + + +def get_assignment_start(): + input_is_invalid: bool = True + user_input: str = '' + while input_is_invalid: + user_input = input( + 'Enter the assignment start date (yyyy-mm-dd or yyyymmdd) or hit Enter to consider all commits: ') + # validate input + if len(user_input) == 8: + user_input = f'{user_input[:4]}-{user_input[4:6]}-{user_input[-2:]}' + if len(user_input) == 10: + if user_input[:4].isnumeric() and user_input[4] == '-' and user_input[5:7].isnumeric() and \ + user_input[-3] == '-' and user_input[-2:].isnumeric(): + year = int(user_input[:4]) + month = int(user_input[5:7]) + day = int(user_input[-2:]) + if year < 1946: + print('\tThe year must be within modern the era of computing.') + elif year < date.today().year: + if 1 <= month <= 12: + if 1 <= day <= 31: # not worth my time to validate 28/29/30-day months + input_is_invalid = False + else: + print('\tThe day must be between 01 and 31, inclusive.') + else: + print('\tThe month must be between 01 and 12, inclusive.') + elif year == date.today().year: + if month < 1: + print('\tThe month must be between 01 and 12, inclusive.') + elif month < date.today().month: + input_is_invalid = False + elif month == date.today().month: + if day < 1: + print('\tThe day must be between 01 and 31, inclusive.') + elif day <= date.today().day: + input_is_invalid = False + else: + print('\tThe date cannot be later than today.') + else: + print('\tThe date cannot be later than today.') + else: + print('\tThe date cannot be later than today.') + else: + print('\tValidation failed due to non-numeric input.') + elif len(user_input) == 0: + input_is_invalid = False + else: + print('\tValidation failed due to incorrect number of characters.') + return user_input + def structure_text(text: str) -> List[str]: return textwrap.wrap(strip_html(text)) @@ -31,7 +84,8 @@ def display_peer_reviews(assignment, students): def get_project_prefix(canvas_groups): name_segments = canvas_groups[0].get_name().split() - prefix = input(f'What is the prefix of the gitlab project names? [{name_segments[0]}] ') + prefix = input(f'What is the prefix of the gitlab project names (enter * if no common prefix exists)? ' + f'[{name_segments[0]}] ') if prefix == '': prefix = name_segments[0] return prefix @@ -39,17 +93,22 @@ def get_project_prefix(canvas_groups): def display_git_contributions(project: GitlabProject): # TODO: recognize that this only works for projects in namespace; will need to ask whether to retrieve project. - commits: List[GitlabCommit] = project.get_commits() # TODO: narrow the selection - contributions: Dict[str, int] = {} # TODO: also broaden to multiple branches? + # noinspection PyUnusedLocal + project_commits: List[GitlabCommit] + if assignment_start_date == '': + project_commits = project.get_commits() + else: + project_commits = project.get_commits(after_date=assignment_start_date) + contributions: Dict[str, int] = {} # TODO: also broaden to multiple branches? contributors: Set[Tuple[str, str]] = set() # noinspection PyShadowingNames - for commit in commits: + for commit in project_commits: if not commit.is_merge(): author = commit.get_author() contributors.add((author['name'], author['email'])) - email = author['email'] # TODO: manage aliases - size = commit.get_diff_size() # TODO: distinguish between file types - if email != 'bohn@unl.edu': # TODO: un-hard-code this + email = author['email'] # TODO: manage aliases + size = commit.get_diff_size() # TODO: distinguish between file types + if email != 'bohn@unl.edu': # TODO: un-hard-code this if email not in contributions: contributions[email] = 0 contributions[email] += size @@ -59,27 +118,39 @@ def display_git_contributions(project: GitlabProject): print(f'\t{str(contributions[contribution]).rjust(5)}\t{contributor}') +# noinspection PyUnusedLocal def grade(assignment1, assignment2, students): print('Enter grades through Canvas gradebook') if __name__ == '__main__': + print('Establishing connections to Canvas and Gitlab...') course = CanvasCourse(Course.canvas_course_id) projects = GitlabProject.get_projects_by_group(Course.gitlab_namespace) - print('First, select the "peer review" assignment to review and grade.\n') + print('Connections established.') + print('First, select the "peer review" assignment to review and grade (select 0 if not grading peer reviews).\n') assignment_groups = course.get_assignment_groups() - assignment_group = select_from_list(assignment_groups, 'assignment group') + assignment_group = select_from_list(assignment_groups, 'assignment group', True) + grading_peer_reviews = assignment_group != 0 print() - assignments = assignment_group.get_assignments() - peer_review_assignment = select_from_list(assignments, 'assignment') - print(f'\nSelected {peer_review_assignment}.') + peer_review_assignment = None + if grading_peer_reviews: + assignments = assignment_group.get_assignments() + peer_review_assignment = select_from_list(assignments, 'assignment') + print(f'\nSelected {peer_review_assignment}.') # TODO: select second assignment (git history) + print('Are you grading git histories?') + options = ['Yes', 'No'] + option = select_from_list(options, 'option') + grading_git_histories = option == 1 + print() print('Now select the student groupset with the teams.\n') student_groupsets = course.get_user_groupsets() student_groupset = select_from_list(student_groupsets, 'groupset') print(f'\nSelected {student_groupset}.\n') student_groups = student_groupset.get_groups() project_prefix = get_project_prefix(student_groups) + no_common_prefix: bool = True if project_prefix == '*' else False print('Are you grading all groups, or are you revisiting a specific group?') options = ['All groups', 'Specific group'] option = select_from_list(options, 'option') @@ -87,12 +158,19 @@ if __name__ == '__main__': print('Which group?') student_groups = [select_from_list(student_groups, 'student group')] zero_padding: int = ceil(log10(len(projects))) - for student_group in student_groups: # TODO: Skip past graded groups + assignment_start_date = get_assignment_start() + for student_group in student_groups: # TODO: Skip past graded groups input(f'\n\nPress Enter to grade {student_group}') print() - project_name = f'{project_prefix}{student_group.get_name().split()[1]}'.zfill(zero_padding) - display_git_contributions(list(filter(lambda p: p.get_name() == project_name, projects))[0]) - display_peer_reviews(peer_review_assignment, student_group.get_students()) + if grading_git_histories: + if no_common_prefix: + custom_project = input(f'Enter path to {student_group.get_name()}\'s repository: ') + display_git_contributions(GitlabProject(custom_project)) + else: + project_name = f'{project_prefix}{student_group.get_name().split()[1]}'.zfill(zero_padding) + display_git_contributions(list(filter(lambda p: p.get_name() == project_name, projects))[0]) + if grading_peer_reviews: + display_peer_reviews(peer_review_assignment, student_group.get_students()) # TODO: Ask if you want to grade (keep track of groups that you don't grade) if True: grade(peer_review_assignment, None, student_group.get_students())