diff --git a/api/course.py b/api/course.py
index fd77bdfa7a0e999c58510fc107354d19d4bdb06c..5a907ff767a779cd184bb06c2c411b0b2ffc9460 100644
--- a/api/course.py
+++ b/api/course.py
@@ -3,5 +3,6 @@ class Course:
     gitlab_namespace = 'csce_361/sandbox'
 
     # Canvas course information
-    canvas_course_id = None
+    # canvas_course_id = '73696'  # Software Engineering Sandbox
+    canvas_course_id = '66898'  # CSCE 361-1198
 
diff --git a/api/gitlab_functions.py b/api/gitlab_functions.py
index c43f72eb1514a62abfc12b8b116e633fe2bbd1e7..797e859b47f64022a88ede98ca2fdc35c113e57f 100644
--- a/api/gitlab_functions.py
+++ b/api/gitlab_functions.py
@@ -156,6 +156,9 @@ def create_issue(project, title, description):
 
 if __name__ == '__main__':
     git = gitlab.Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key)
+    project = get_project_by_id(git, '5484')
+    student = get_user_by_id(git, project.creator_id)
+    print(f'{student.name} forked repo at {project.created_at}')
     print('getting a user, by name')
     print(get_user_by_name(git, 'bohn'))
     print('getting a user by user ID and printing only the user\'s name')
diff --git a/gitlab_classes.py b/gitlab_classes.py
new file mode 100644
index 0000000000000000000000000000000000000000..87328fd0afe1e4b1a20785d025acc8658dbae4e9
--- /dev/null
+++ b/gitlab_classes.py
@@ -0,0 +1,325 @@
+from datetime import datetime
+import gitlab
+from config import Config
+
+
+def gitlab_timestamp_to_datetime(timestamp):
+    return datetime.fromisoformat(timestamp)
+
+
+class Session:
+    __instance = None
+
+    @staticmethod
+    def get_session():
+        if Session.__instance is None:
+            Session.__instance = gitlab.Gitlab(Config.gitlab_url, private_token=Config.gitlab_api_key)
+        return Session.__instance
+
+
+class User:
+    def __init__(self, user):
+        """
+        Creates a User object, populating the backing git_user instance with the appropriate gitlab.User object
+        :param user: must be either a gitlab.User object, an integer representing the user ID, or a string containing
+        the username
+        """
+        super().__init__()
+        if isinstance(user, int):               # by user id
+            self.git_user = Session.get_session().users.get(user)
+        elif isinstance(user, str):             # by username
+            self.git_user = Session.get_session().users.list(username=user)[0]
+        else:
+            self.git_user = user
+
+    def get_user_id(self):
+        return self.git_user.id
+
+    def get_name(self):
+        return self.git_user.name
+
+    def get_username(self):
+        return self.git_user.username
+
+    def get_site(self):
+        return self.git_user.web_url
+
+
+class Issue:
+    def __init__(self, issue):
+        """
+        Creates an Issue object, populating the backing git_issue instance with the appropriate gitlab.Issue object
+        :param issue: must be a gitlab.Issue object
+        """
+        super().__init__()
+        self.git_issue = issue
+
+    def get_universal_issue_id(self):
+        """
+        :return: universally-unique identifier
+        """
+        return self.git_issue.id
+
+    def get_project_issue_id(self):
+        """
+        :return: project-unique identifier
+        """
+        return self.git_issue.iid
+
+    def get_state(self):
+        """
+        :return: opened or closed
+        """
+        return self.git_issue.state
+
+    def get_created_at(self):
+        """
+        :return: an "aware" datetime object representing the creation date/time
+        """
+        created_at = self.git_issue.created_at
+        if created_at[-1] in ('z', 'Z'):        # Didn't encounter this problem with created_at
+            created_at = created_at[:-1] + '+00:00'
+        return datetime.fromisoformat(created_at)
+
+    def get_updated_at(self):
+        """
+        :return: an "aware" datetime object representing the last date/time the issue was updated
+        """
+        updated_at = self.git_issue.updated_at
+        if updated_at[-1] in ('z', 'Z'):        # Didn't encounter this problem with updated_at
+            updated_at = updated_at[:-1] + '+00:00'
+        return datetime.fromisoformat(updated_at)
+
+    def get_closed_at(self):
+        """
+        :return: an "aware" datetime object representing the last date/time the issue was closed, or None if the issue
+        is open
+        """
+        closed_at = self.git_issue.closed_at
+        if closed_at is None:
+            return None
+        else:
+            if closed_at[-1] in ('z', 'Z'):     # Did encounter this problem with closed_at
+                closed_at = closed_at[:-1] + '+00:00'
+            return datetime.fromisoformat(closed_at)
+
+    def get_labels(self):
+        """
+        :return: list of labels
+        """
+        return self.git_issue.labels.list(all=True)
+
+    def get_page(self):
+        """
+        :return: HTTPS URL to issue's page
+        """
+        return self.git_issue.web_url
+
+    # other git_issue fields:
+    # project_id
+    # title
+    # description
+    # closed_by             user
+    # milestone
+    # assignees             list of users
+    # author                user
+    # assignee              user
+    # user_notes_count
+    # merge_requests_count
+    # upvotes
+    # downvotes
+    # due_date              same date format, or None
+    # confidential
+    # discussion_locked
+    # time_stats
+    # task_completion_status
+    # has_tasks
+    # _links
+    # notes
+    # award_emoji
+    # project
+    # subscribed
+
+
+class Project:
+    def __init__(self, project):
+        """
+        Creates a Project object, populating the backing git_project instance with the appropriate gitlab.Project object
+        :param project: must be either a gitlab.Project object, an integer representing the project ID, or a string
+        containing the project's path (namespace and name, such as 'csce_361/sandbox/HelloWorld')
+        """
+        super().__init__()
+        if isinstance(project, int):            # by project id
+            self.git_project = Session.get_session().projects.get(project)
+        elif isinstance(project, str):          # by path
+            self.git_project = Session.get_session().projects.get(project)
+        else:
+            self.git_project = project
+
+    @staticmethod
+    def get_projects_by_group(group_id):
+        gitlab_projects = Session.get_session().groups.get(group_id).projects.list(all=True)
+        projects = []
+        for project in gitlab_projects:
+            projects.append(Project(project))
+        return projects
+
+    @staticmethod
+    def get_projects_by_keyword(search_term):
+        gitlab_projects = Session.get_session().projects.list(search=search_term, all=True)
+        projects = []
+        for project in gitlab_projects:
+            projects.append(Project(project))
+        return projects
+
+    @staticmethod
+    def create_project(project_name):
+        return Session.get_session().projects.create({'name': project_name})
+
+    @staticmethod
+    def create_project_in_group(group_name, project_name):
+        group_id = Session.get_session().groups.get(group_name).id
+        return Session.get_session().projects.create({'name': project_name, 'namespace_id': group_id})
+
+    def get_project_id(self):
+        return self.git_project.id
+
+    def get_name(self):
+        """
+        :return: project name without namespace
+        """
+        return self.git_project.name
+
+    def get_name_with_namespace(self):
+        """
+        :return: project name with namespace, spaces around slashes
+        """
+        return self.git_project.name_with_namespace
+
+    def get_path(self):
+        """
+        :return: path without namespace (may differ from name if name has spaces)
+        """
+        return self.git_project.path
+
+    def get_path_with_namespace(self):
+        """
+        :return: path with namespace, no spaces around slashes
+        """
+        return self.git_project.path_with_namespace
+
+    def get_cloning_url(self):
+        """
+        :return: SSH URL to clone repository
+        """
+        return self.git_project.ssh_url_to_repo
+
+    def get_site(self):
+        """
+        :return: HTTPS URL to git site
+        """
+        return self.git_project.web_url
+
+    def get_readme_url(self):
+        """
+        :return: HTTPS URL to README.md
+        """
+        return self.git_project.readme_url
+
+    def get_visibility(self):
+        """
+        :return: 'private', etc.
+        """
+        return self.git_project.visibility
+
+    def get_creator(self):
+        """
+        :return: User object backed by the gitlab.User object representing the user who created the repo
+        """
+        return User(self.git_project.creator_id)
+
+    def get_users(self):
+        """
+        :return: List of User objects representing the project's members (not including inherited members)
+        """
+        gitlab_users = self.git_project.members.list(all=True)
+        users = []
+        for user in gitlab_users:
+            users.append(User(user))
+        return users
+
+    def get_all_users(self):
+        """
+        :return: List of User objects representing all of the project's members (including inherited members)
+        """
+        gitlab_users = self.git_project.members.all(all=True)
+        users = []
+        for user in gitlab_users:
+            users.append(User(user))
+        return users
+
+    def add_user_as_maintainer(self, user):
+        self.git_project.members.create({'user_id': user.get_user_id(), 'access_level': gitlab.MAINTAINER_ACCESS})
+
+    def get_issues(self):
+        """
+        :return: List of Issue objects representing project's issues, sorted by creation date
+        """
+        gitlab_issues = self.git_project.issues.list(order_by='created_at', sort='asc', all=True)
+        issues = []
+        for issue in gitlab_issues:
+            issues.append(Issue(issue))
+        return issues
+
+    def create_issue(self, title, description):
+        gitlab_issue = self.git_project.issues.create({'title': title, 'description': description})
+        return Issue(gitlab_issue)
+
+    # other git_project fields:
+    # description
+    # created_at
+    # default_branch
+    # tag_list
+    # http_url_to_repo      https URL to clone repository
+    # avatar_url
+    # star_count
+    # forks_count
+    # last_activity_at
+    # namespace             namespace's JSON object
+    # _links                JSON object with api/v4 links to self, issues, merge_requests,
+    #                                                        repo_branches, labels, events, members
+    # empty_repo
+    # archived
+    # resolve_outdated_diff_discussions
+    # container_registry_enabled
+    # issues_enabled
+    # merge_requests_enabled
+    # jobs_enabled
+    # snippets_enabled
+    # issues_access_level
+    # repository_access_level,
+    # wiki_access_level
+    # builds_access_level
+    # snippets_access_level
+    # shared_runners_enabled
+    # lfs_enabled
+    # import_status
+    # import_error
+    # open_issues_count
+    # runners_token
+    # ci_default_git_depth
+    # public_jobs
+    # build_git_strategy
+    # build_timeout
+    # auto_cancel_pending_pipelines
+    # build_coverage_regex
+    # ci_config_path
+    # shared_with_groups
+    # only_allow_merge_if_pipeline_succeeds
+    # request_access_enabled
+    # only_allow_merge_if_all_discussions_are_resolved
+    # printing_merge_request_link_enabled
+    # merge_method
+    # auto_devops_enabled
+    # auto_devops_deploy_strategy
+    # permissions