diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..f6fa89f9ac8e46c9af0a50698a250fdbd9dc6ef1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,31 @@
+# Project-specific
+#
+
+# Mac file finder metadata
+.DS_Store
+# Windows file metadata
+._*
+# Old file browser metadata
+Thumbs.db
+
+# Emacs backup file
+*~
+
+# Python files
+*.pyc
+*.pyo
+__pycache__/
+.venv/
+
+# JetBrains (IntelliJ IDEA, PyCharm, etc) files
+.idea/
+*.iml
+*.iws
+*.ipr
+
+# Miscellaneous
+tmp/
+*.tmp
+*.bak
+*.swp
+
diff --git a/Correlate231Grades.py b/Correlate231Grades.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9a5c97e14a679486318d05c1c966c9d4b8d604b
--- /dev/null
+++ b/Correlate231Grades.py
@@ -0,0 +1,113 @@
+import csv
+from getpass import getpass
+from typing import Dict, List
+
+from sqlalchemy.orm import Session
+
+from StudentsDatabase import Enrollment, StudentsDatabase
+
+target_course = 'CSCE231'
+other_courses = {
+    'CS1': ['CSCE155A', 'CSCE155E', 'CSCE155H', 'CSCE155N', 'CSCE155T', 'RAIK183H', 'SOFT160', 'SOFT160H'],
+    'CS2': ['CSCE156', 'CSCE156H', 'RAIK184H', 'SOFT161', 'SOFT161H'],
+    'Discrete Math': ['CSCE235', 'CSCE235H', 'RAIK184H'],
+    'Data Structures & Algorithms': ['CSCE310', 'CSCE310H', 'RAIK283H', 'SOFT260', 'SOFT260H'],
+    'Language Concepts': ['CSCE322', 'CSCE322H']
+}
+csv_fields = ['Semester', 'Student', 'CSCE 231 Grade Category',
+              'CS1 course', 'CS1 grade', 'CS1 failures',
+              'CS2 course', 'CS2 grade', 'CS2 failures',
+              'Discrete Math course', 'Discrete Math grade', 'Discrete Math failures',
+              'Data Structures & Algorithms course', 'Data Structures & Algorithms grade', 'Data Structures & Algorithms failures',
+              'Language Concepts course', 'Language Concepts grade', 'Language Concepts failures']
+
+categorized_grades = {
+    'A+': ['A+'],
+    'A': ['A', 'A-'],
+    'B': ['B+', 'B', 'B-'],
+    'C': ['C+', 'C', 'P'],
+    'DFW': ['C-', 'D+', 'D', 'D-', 'F', 'W', 'N']
+}
+
+semester_codes = ['1218', '1221', '1228', '1231', '1238', '1241']
+# semester_codes = ['1241']
+
+
+def categorize_students(session: Session, semester_code: str) -> Dict[str, List[str]]:
+    categorized_students: Dict[str, List[str]] = {}
+    for grade_category in categorized_grades:
+        students = (session
+                    .query(Enrollment.nuid)
+                    .filter(Enrollment.semester_code == semester_code,
+                            Enrollment.course == target_course,
+                            Enrollment.grade.in_(categorized_grades[grade_category]))
+                    .all())
+        categorized_students[grade_category] = list([str(student[0]) for student in students])
+    return categorized_students
+
+
+def get_other_grades(session: Session, categorized_students: Dict[str, List[str]]) -> List[Dict[str, str]]:
+    other_grades: List[Dict[str, str]] = []
+    for course_category in other_courses:
+        for grade_category in categorized_grades:
+            print(f'\tCSCE231: {grade_category}\t\tcourse category: {course_category}')
+            grades = (session
+                      .query(Enrollment.nuid, Enrollment.course, Enrollment.grade)
+                      .filter(Enrollment.course.in_(other_courses[course_category]),
+                              Enrollment.nuid.in_(categorized_students[grade_category]),
+                              Enrollment.grade is not None, Enrollment.grade != '')
+                      .all())
+            for grade in grades:
+                other_grades.append({
+                    'student': grade[0],
+                    'course category': course_category,
+                    'course': grade[1],
+                    'grade': grade[2]
+                })
+    return other_grades
+
+
+def create_csv_rows(semester_code: str, categorized_students: Dict[str, List[str]],
+                    other_grades: List[Dict[str, str]]) -> List[Dict[str, str]]:
+    rows: List[Dict[str, str]] = []
+    for grade_category in categorized_grades:
+        for student in categorized_students[grade_category]:
+            row = {'Semester': semester_code, 'Student': student, 'CSCE 231 Grade Category': grade_category}
+            student_grades = [grade for grade in other_grades if grade['student'] == student]
+            # course, course category, grade, student -- SOFT160, CS1, A, 02200719
+            for course_category in other_courses:
+                passing_grades = [grade for grade in student_grades if grade['course category'] == course_category
+                                  and grade['grade'] not in categorized_grades['DFW']]
+                failing_grades = [grade for grade in student_grades if grade['course category'] == course_category
+                                  and grade['grade'] in categorized_grades['DFW']]
+                if len(passing_grades) > 0:
+                    passing_grade = passing_grades[0]
+                    row[f'{course_category} course'] = passing_grade['course']
+                    row[f'{course_category} grade'] = passing_grade['grade']
+                else:
+                    row[f'{course_category} course'] = 'n/a'
+                    row[f'{course_category} grade'] = 'n/a'
+                row[f'{course_category} failures'] = str(len(failing_grades))
+            rows.append(row)
+    return rows
+
+
+def main():
+    username = input('Enter your username: ')
+    password = getpass('Enter your password: ')
+    filename: str = input('What file do you want to save the data to? ')
+    session = StudentsDatabase.connect(username, password)
+    with open(filename, 'w') as csvfile:
+        writer = csv.DictWriter(csvfile, fieldnames=csv_fields)
+        writer.writeheader()
+        for semester_code in semester_codes:
+            print(f'Getting students for semester {semester_code}')
+            categorized_students = categorize_students(session, semester_code)
+            print('Getting students\' other grades')
+            other_grades = get_other_grades(session, categorized_students)
+            for row in create_csv_rows(semester_code, categorized_students, other_grades):
+                writer.writerow(row)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/EnrollmentQuantifier.py b/EnrollmentQuantifier.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c4ebaef18e242c5ff319506cab7ff6e2ec568b0
--- /dev/null
+++ b/EnrollmentQuantifier.py
@@ -0,0 +1,90 @@
+import re
+from getpass import getpass
+from typing import List
+
+from sqlalchemy import distinct
+from sqlalchemy.orm import Session
+
+from StudentsDatabase import Enrollment, StudentsDatabase
+
+
+class Course:       # TODO: replace this with a dict
+    def __init__(self, prefix, number, suffix):
+        self.prefix = prefix
+        self.number = number
+        self.suffix = suffix
+        self.processed = False
+
+    def __str__(self):
+        return f'{self.prefix}{self.number}{self.suffix}'
+
+
+def get_courses(session: Session, semester_code: str) -> List[List[str]]:
+    # noinspection PyTypeChecker
+    database_courses = (session
+                        .query(distinct(Enrollment.course))
+                        .filter(Enrollment.semester_code == semester_code)
+                        .all())
+    courses = []
+    combined_courses = []
+    for course in database_courses:
+        match = re.match(r'([a-zA-Z]+)(\d*)([a-zA-Z]?)', str(course[0]))
+        if match:
+            try:
+                courses.append(Course(match.group(1), int(match.group(2)), match.group(3)))
+            except ValueError as error:
+                print(f'Skipping {course[0]} because "{error}"')
+        else:
+            print(f'Skipping {course[0]} because it does not match the regex')
+    courses.sort(key=lambda c: f'{c.number}{c.suffix}')
+    for course in courses:
+        if not course.processed:
+            combined_course = [str(course)]
+            course.processed = True
+            courses_with_same_number = \
+                [other_course for other_course in courses if course != other_course and not other_course.processed
+                 and (course.number == other_course.number or (400 <= course.number <= 499  # TODO prepare for CSCE 377/877
+                                                               and 800 <= other_course.number <= 899
+                                                               and other_course.number == course.number + 400))]
+            for other_course in courses_with_same_number:
+                should_crosslist = input(f'Crosslist {other_course} with {course}? [Y] ')
+                if len(should_crosslist) == 0:
+                    should_crosslist = 'y'
+                if should_crosslist[0].lower() == 'y':
+                    combined_course.append(str(other_course))
+                    other_course.processed = True
+            print(f'Including {combined_course}')
+            combined_courses.append(combined_course)
+    return combined_courses
+
+
+def get_enrollment(session: Session, semester_code: str, courses: List[str]):
+    enrollment = 0
+    for course in courses:
+        # noinspection PyTypeChecker
+        enrollment += len(session
+                          .query(distinct(Enrollment.nuid))
+                          .filter(Enrollment.semester_code == semester_code,
+                                  Enrollment.course == course,
+                                  Enrollment.dropped != 1)
+                          .all())
+    return enrollment
+
+
+def main():
+    username = input('Enter your username: ')
+    password = getpass('Enter your password: ')
+    semester_code = input('Enter semester code: ')
+    session = StudentsDatabase.connect(username, password)
+    courses = get_courses(session, semester_code)
+    for course in courses:
+        enrollment = get_enrollment(session, semester_code, course)
+        for index, subcourse in enumerate(course):
+            if index != 0:
+                print('/', end='')
+            print(subcourse, end='')
+        print(f', {enrollment}')
+
+
+if __name__ == '__main__':
+    main()
diff --git a/FindTAs.py b/FindTAs.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc241e4ee918da76c5005764a8c6f8bdeb2d965a
--- /dev/null
+++ b/FindTAs.py
@@ -0,0 +1,202 @@
+from getpass import getpass
+from typing import List, Dict
+
+from sqlalchemy import distinct
+from sqlalchemy.orm import Session
+
+from StudentsDatabase import StudentsDatabase, Enrollment, semester_code_is_well_formed, CourseSchedule, Student
+
+
+def get_semester_codes(current_semester_code: str) -> List[str]:
+    initial_semester_code = ''
+    semester_code_is_valid = False
+    while not semester_code_is_valid:
+        initial_semester_code = input('What is the earliest semester code to examine? ')
+        semester_code_is_valid = semester_code_is_well_formed(initial_semester_code)
+        if semester_code_is_valid and initial_semester_code >= current_semester_code:
+            print(f'{initial_semester_code} must be earlier than {current_semester_code}.')
+            semester_code_is_valid = False
+    semester_codes = []
+    next_semester_code = initial_semester_code
+    while next_semester_code < current_semester_code:
+        semester_codes.append(next_semester_code)
+        century_year = int(next_semester_code[:-1])
+        month = int(next_semester_code[-1])
+        match month:
+            case 1:
+                month = 5
+            case 5:
+                month = 8
+            case 8:
+                month = 1
+            case _:
+                print('Reached unreachable code!')
+                exit(1)
+        if month == 1:
+            century_year += 1
+        next_semester_code = f'{century_year}{month}'
+    return semester_codes
+
+
+def get_allowable_grades() -> List[str]:
+    grade = ''
+    grade_is_well_formed = False
+    while not grade_is_well_formed:
+        grade = input('What is the minimum grade to consider? ').upper()
+        grade_is_well_formed = (1 <= len(grade) <= 2 and grade[0] in {'A', 'B', 'C', 'D', 'F'}
+                                and (grade[1] in {'+', '-'} if len(grade) == 2 else True))
+        if not grade_is_well_formed:
+            print(f'{grade} is not a valid grade.')
+    grades = [grade]
+    while grade != 'A+':
+        if len(grade) == 1:
+            grade = f'{grade}+'
+        elif grade[1] == '-':
+            grade = grade[0]
+        else:
+            grade = f'{chr(ord(grade[0]) - 1)}-'
+        grades.append(grade)
+    return grades
+
+
+# noinspection PyPep8Naming
+def get_candidate_TAs(session: Session, course_code: str,
+                      semester_codes: List[str], allowable_grades: List[str]) -> List[Dict[str, str]]:
+    # noinspection PyTypeChecker
+    students = (session
+                .query(Enrollment)
+                .filter(Enrollment.course == course_code,
+                        Enrollment.semester_code.in_(semester_codes),
+                        Enrollment.grade.in_(allowable_grades))
+                .all())
+    return [dict(nuid=str(student.nuid), grade=str(student.grade), semester_code=str(student.semester_code))
+            for student in students]
+
+
+# noinspection DuplicatedCode
+def filter_on_time_window(session: Session, semester_code: str,
+                          candidates: List[Dict[str, str]]) -> List[Dict[str, str]]:
+    day = ''
+    while day == '':
+        day = input('Which day are you looking for (M/T/W/R/F)? ').upper()
+        match day:
+            case 'M' | 'T' | 'W' | 'R' | 'F':
+                day = day
+            case 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'FRIDAY':
+                day = day[0]
+            case 'THURSDAY':
+                day = 'R'
+            case _:
+                print(f'{day} is not a valid day.')
+                day = ''
+    start_time = ''
+    while start_time == '':
+        start_time = input('What is the start time (use a 24-hour clock)? ').replace(':', '')
+        if not start_time.isdigit() or len(start_time) < 3 or len(start_time) > 4:
+            print(f'{start_time} is not a valid 24-hour time.')
+            start_time = ''
+        elif len(start_time) == 3:
+            start_time = f'0{start_time}'
+        if start_time and (int(start_time[0]) > 2 or int(start_time[2]) > 5):
+            print(f'{start_time} is not a valid 24-hour time.')
+            start_time = ''
+    end_time = ''
+    while end_time == '':
+        end_time = input('What is the ending time (use a 24-hour clock)? ').replace(':', '')
+        if not end_time.isdigit() or len(end_time) < 3 or len(end_time) > 4:
+            print(f'{end_time} is not a valid 24-hour time.')
+            end_time = ''
+        elif len(end_time) == 3:
+            end_time = f'0{end_time}'
+        if end_time and (int(end_time[0]) > 2 or int(end_time[2]) > 5):
+            print(f'{end_time} is not a valid 24-hour time.')
+            end_time = ''
+        if end_time < start_time:
+            print(f'The ending time must be later than the start time.')
+            end_time = ''
+    print(
+        'NOTE: Filtering will only check for School of Computing courses; there may yet be conflicts with other departments\' courses.')
+    # noinspection PyTypeChecker
+    candidate_enrollments = (session
+                             .query(Enrollment.nuid,
+                                    CourseSchedule.start_time,
+                                    CourseSchedule.end_time)
+                             .join(CourseSchedule,
+                                   (Enrollment.course == CourseSchedule.course) &
+                                   (Enrollment.section == CourseSchedule.section))
+                             .filter(Enrollment.nuid.in_([candidate['nuid'] for candidate in candidates]),
+                                     Enrollment.semester_code == semester_code,
+                                     CourseSchedule.days.like(f'%{day}%'))
+                             .distinct(CourseSchedule.start_time, CourseSchedule.end_time)
+                             .all())
+    # TODO: add filter for dropped != 1
+    filtered_candidates = []
+    for candidate in candidates:
+        has_schedule_conflict = False
+        enrollments = [enrollment for enrollment in candidate_enrollments if enrollment.nuid == candidate['nuid']]
+        for enrollment in enrollments:
+            enrollment_start_time = enrollment.start_time.replace(':', '')
+            enrollment_end_time = enrollment.end_time.replace(':', '')
+            if (enrollment_start_time <= start_time < enrollment_end_time
+                    or enrollment_start_time < end_time <= enrollment_end_time):
+                # TODO: troubleshoot -- this allowed at least one CSCE course 1230-1320 through
+                has_schedule_conflict = True
+        if not has_schedule_conflict:
+            filtered_candidates.append(candidate)
+    return filtered_candidates
+
+
+# noinspection DuplicatedCode
+def main():
+    username = input('Enter your username: ')
+    password = getpass('Enter your password: ')
+    session = StudentsDatabase.connect(username, password)
+    course_code = input('Which course are you searching? ').upper().replace(' ', '')
+    current_semester_code = ''
+    semester_code_is_valid = False
+    while not semester_code_is_valid:
+        current_semester_code = input('What is current semester code? ')
+        semester_code_is_valid = semester_code_is_well_formed(current_semester_code)
+    semester_codes = get_semester_codes(current_semester_code)
+    allowable_grades = get_allowable_grades()
+    # TODO: determine if we need to further restrict the semesters and/or grades
+    candidates = get_candidate_TAs(session, course_code, semester_codes, allowable_grades)
+    print('Number of matching candidates found...')
+    for semester_code in semester_codes:
+        if len([candidate for candidate in candidates if candidate['semester_code'] == semester_code]) > 0:
+            print(f'{semester_code} -- ', end='\t')
+            for grade in allowable_grades:
+                print(f'{grade} {len([candidate for candidate in candidates
+                                      if candidate['semester_code'] == semester_code and candidate['grade'] == grade])}',
+                      end='\t')
+            print()
+    looking_for_time_window = input('Are you looking for a specific time window? [N] ').upper()
+    if len(looking_for_time_window) > 0 and looking_for_time_window[0] == 'Y':
+        candidates = filter_on_time_window(session, current_semester_code, candidates)
+    # print('Number of matching candidates who have no known course conflicts...')
+    # for semester_code in semester_codes:
+    #     if len([candidate for candidate in candidates if candidate['semester_code'] == semester_code]) > 0:
+    #         print(f'{semester_code} -- ', end='\t')
+    #         for grade in allowable_grades:
+    #             print(f'{grade} {len([candidate for candidate in candidates
+    #                                   if candidate['semester_code'] == semester_code and candidate['grade'] == grade])}',
+    #                   end='\t')
+    #         print()
+    students = (session
+                .query(Student)
+                .filter(Student.nuid.in_([candidate['nuid'] for candidate in candidates]))
+                .distinct(Student.nuid)
+                .all())
+    # for student in students:
+    #     other_data = [candidate for candidate in candidates if candidate['nuid'] == student.nuid][0]
+    #     semester_code = other_data['semester_code']
+    #     grade = other_data['grade']
+    for candidate in candidates:
+        semester_code = candidate['semester_code']
+        grade = candidate['grade']
+        student = [student for student in students if student.nuid == candidate['nuid']][0]
+        print(f'{semester_code} {grade}\t{student.first_name} {student.last_name}')
+
+
+if __name__ == '__main__':
+    main()
diff --git a/StudentsDatabase.py b/StudentsDatabase.py
new file mode 100644
index 0000000000000000000000000000000000000000..77419cb2d332b94db8c6f24664eb6b143fc1f192
--- /dev/null
+++ b/StudentsDatabase.py
@@ -0,0 +1,127 @@
+from typing import Union
+
+from _mysql_connector import MySQLInterfaceError
+from mysql.connector import errors
+from sqlalchemy import create_engine, Column, Integer, String, Date, exc, ForeignKey
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker, Session #, relationship
+
+
+def semester_code_is_well_formed(current_semester_code: str) -> bool:
+    is_well_formed = (len(current_semester_code) == 4 and current_semester_code.isdigit()
+                      and current_semester_code[0] == '1' and current_semester_code[-1] in {'1', '5', '8'})
+    # we'll assume we don't want to go back to the 20th century
+    # we'll assume semesters only start in January, May, or August
+    if not is_well_formed:
+        print(f'{current_semester_code} is not a valid semester code. '
+              f'See https://registrar.unl.edu/academic-standards/policies/year-term-identifier/')
+    return is_well_formed
+
+
+Base = declarative_base()
+
+
+class Student(Base):
+    __tablename__ = 'students'
+    id = Column(Integer, primary_key=True)
+    semester_code = Column(String(4))
+    nuid = Column(String(11), unique=True)
+    first_name = Column(String(50))
+    middle_name = Column(String(255))
+    last_name = Column(String(50))
+    rest_name = Column(String(15))
+    enrolled = Column(Integer)
+    college = Column(String(15))
+    major = Column(String(15))
+    student_year = Column(String(10))
+    gpa = Column(String(15))
+    raiks_student = Column(Integer)
+    email = Column(String(255))
+    unl_uid = Column(String(255))
+    private = Column(Integer)
+    dropped = Column(Integer)
+    insert_date = Column(Date)
+    # Relationship to Enrollments
+    # enrollments = relationship("Enrollment", back_populates="student")
+
+
+class Enrollment(Base):
+    __tablename__ = 'enrollments'
+    id = Column(Integer, primary_key=True)
+    semester_code = Column(String(4))
+    nuid = Column(String(11), ForeignKey('students.nuid'))
+    course = Column(String(11))
+    section = Column(String(11))
+    grade = Column(String(5))
+    override = Column(String(25))
+    dropped = Column(Integer)
+    insert_date = Column(Date)
+    # Relationship to Student
+    # student = relationship("Student", back_populates="enrollments")
+    # Relationship to CourseSchedule
+    # noinspection PyTypeChecker
+    # course_schedule = relationship("CourseSchedule", back_populates="enrollments", foreign_keys=[course, section])
+
+
+class CourseSchedule(Base):
+    __tablename__ = 'course_schedules'
+    id = Column(Integer, primary_key=True)
+    academic_session_id = Column(Integer)
+    semester_code = Column(String(4))
+    call_number = Column(String(11))
+    course = Column(String(11))
+    section = Column(String(15))
+    title = Column(String(50))
+    topic = Column(String(255))
+    department = Column(String(15))
+    credits = Column(String(4))
+    enrollment = Column(Integer)
+    instructor = Column(String(50))
+    instructor_nuid = Column(String(12))
+    activity = Column(String(5))
+    building = Column(String(15))
+    room = Column(String(10))
+    days = Column(String(7))
+    start_time = Column(String(10))
+    end_time = Column(String(10))
+    active = Column(Integer)
+    hidden = Column(Integer)
+    override = Column(Integer)
+    insert_date = Column(Date)
+    # Relationship to Enrollments
+    # enrollments = relationship("Enrollment", back_populates="course_schedule")
+
+
+class StudentsDatabase(object):
+    @staticmethod
+    def connect(username: str, password: str,
+                host: str = 'cse-apps.unl.edu', port: int = 3306, database: str = 'students') -> Session:
+        url = StudentsDatabase.construct_mysql_url(host, port, database, username, password)
+        try:
+            students_database = StudentsDatabase(url)
+            session = students_database.create_session()
+        except exc.ProgrammingError | errors.ProgrammingError | MySQLInterfaceError as error:
+            # we don't seem to actually be able to catch the exception!
+            print('Could not connect to the database.')
+            print(error)
+            exit(1)
+        return session
+
+    @staticmethod
+    def construct_mysql_url(authority: str, port: Union[int, str], database: str, username: str, password: str) -> str:
+        return f'mysql+mysqlconnector://{username}:{password}@{authority}:{port}/{database}'
+
+    @staticmethod
+    def construct_in_memory_url() -> str:
+        return 'sqlite:///'
+
+    def __init__(self, url):
+        self.engine = create_engine(url)
+        self.Session = sessionmaker()
+        self.Session.configure(bind=self.engine)
+
+    def ensure_tables_exist(self) -> None:
+        Base.metadata.create_all(self.engine)
+
+    def create_session(self) -> Session:
+        return self.Session()