From bc31b709d35bd0ac57afb6f5d81e13e5727f375a Mon Sep 17 00:00:00 2001
From: Christopher Bohn <bohn@unl.edu>
Date: Sun, 22 Mar 2020 17:06:20 -0500
Subject: [PATCH] added flexibility to grade peer review, git history, both, or
 neither

---
 api/gitlab_classes.py      |   1 -
 common_functions.py        |  31 +++++++++--
 grade_team_contribution.py | 110 +++++++++++++++++++++++++++++++------
 3 files changed, 119 insertions(+), 23 deletions(-)

diff --git a/api/gitlab_classes.py b/api/gitlab_classes.py
index 804e7e7..8d213be 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 0c98e5f..79f374e 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 c090ad1..1f7b5d9 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())
-- 
GitLab