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)