diff --git a/eduhelx_jupyterlab_student/handlers.py b/eduhelx_jupyterlab_student/handlers.py index 7f8e5bf..28842da 100644 --- a/eduhelx_jupyterlab_student/handlers.py +++ b/eduhelx_jupyterlab_student/handlers.py @@ -16,6 +16,7 @@ from tornado.websocket import WebSocketHandler as WSHandler from jupyter_server.utils import url_path_join from pathlib import Path +from datetime import datetime from collections.abc import Iterable from .config import ExtensionConfig from eduhelx_utils.git import ( @@ -24,26 +25,16 @@ get_tail_commit_id, get_repo_name, add_remote, stage_files, commit, push, get_commit_info, get_modified_paths, get_repo_root as get_git_repo_root, - checkout, reset as git_reset, get_head_commit_id, - merge as git_merge, abort_merge, delete_local_branch, - is_ancestor_commit, diff_status as git_diff_status + checkout, reset as git_reset, get_head_commit_id, merge as git_merge, + abort_merge, delete_local_branch, is_ancestor_commit, + stash_changes, pop_stash, diff_status as git_diff_status, + restore as git_restore, rm as git_rm ) from eduhelx_utils.api import Api, AuthType, APIException from eduhelx_utils.process import execute from .student_repo import StudentClassRepo, NotStudentClassRepositoryException from ._version import __version__ -FIXED_REPO_ROOT = "eduhelx/{}-student" # -ORIGIN_REMOTE_NAME = "origin" -UPSTREAM_REMOTE_NAME = "upstream" -# Local branch -MAIN_BRANCH_NAME = "main" -# We have to do a merge to sync changes. We stage the merge on a separate branch -# to proactively guard against a merge conflict. -MERGE_STAGING_BRANCH_NAME = "__temp__/merge_{}-from-{}" # Formatted with the local head and upstream head commit hashes -UPSTREAM_TRACKING_BRANCH = f"{ UPSTREAM_REMOTE_NAME }/{ MAIN_BRANCH_NAME }" -ORIGIN_TRACKING_BRANCH = f"{ ORIGIN_REMOTE_NAME }/{ MAIN_BRANCH_NAME }" - class AppContext: def __init__(self, serverapp): self.serverapp = serverapp @@ -69,12 +60,7 @@ def __init__(self, serverapp): async def get_repo_root(self): course = await self.api.get_course() - return self._compute_repo_root(course["name"]) - - @staticmethod - def _compute_repo_root(course_name: str): - # NOTE: the relative path for the server is the root path for the UI - return Path(FIXED_REPO_ROOT.format(course_name.replace(" ", "_"))) + return StudentClassRepo._compute_repo_root(course["name"]) class BaseHandler(APIHandler): @@ -285,7 +271,7 @@ async def post(self): # so that we don't push the stages changes without actually creating a submission for the user # (which would be very misleading) try: - push(ORIGIN_REMOTE_NAME, MAIN_BRANCH_NAME, path=current_assignment_path) + push(StudentClassRepo.ORIGIN_REMOTE_NAME, StudentClassRepo.MAIN_BRANCH_NAME, path=current_assignment_path) self.finish() except Exception as e: # Need to rollback the commit if push failed too. @@ -293,6 +279,27 @@ async def post(self): self.set_status(500) self.finish(str(e)) +class NotebookFilesHandler(BaseHandler): + @tornado.web.authenticated + async def get(self): + course = await self.api.get_course() + assignments = await self.api.get_my_assignments() + + assignment_notebooks = {} + for assignment in assignments: + repo_root = StudentClassRepo._compute_repo_root(course["name"]).resolve() + assignment_path = repo_root / assignment["directory_path"] + + notebooks = [path.relative_to(assignment_path) for path in assignment_path.rglob("*.ipynb")] + notebooks = [path for path in notebooks if ".ipynb_checkpoints" not in path.parts] + # Sort by nestedness, then alphabetically + notebooks.sort(key=lambda path: (len(path.parents), str(path))) + + assignment_notebooks[assignment["id"]] = [str(path) for path in notebooks] + + self.finish(json.dumps({ + "notebooks": assignment_notebooks + })) class SettingsHandler(BaseHandler): @tornado.web.authenticated @@ -307,13 +314,13 @@ async def get(self): async def create_repo_root_if_not_exists(context: AppContext, course) -> None: - repo_root = context._compute_repo_root(course["name"]) + repo_root = StudentClassRepo._compute_repo_root(course["name"]) if not repo_root.exists(): repo_root.mkdir(parents=True) async def create_ssh_config_if_not_exists(context: AppContext, course, student) -> None: settings = await context.api.get_settings() - repo_root = context._compute_repo_root(course["name"]).resolve() + repo_root = StudentClassRepo._compute_repo_root(course["name"]).resolve() ssh_config_dir = repo_root / ".ssh" ssh_config_file = ssh_config_dir / "config" ssh_identity_file = ssh_config_dir / "id_gitea" @@ -337,7 +344,10 @@ async def create_ssh_config_if_not_exists(context: AppContext, course, student) if not ssh_identity_file.exists(): ssh_config_dir.mkdir(parents=True, exist_ok=True) + execute(["chmod", "700", ssh_config_dir]) execute(["ssh-keygen", "-t", "rsa", "-f", ssh_identity_file, "-N", ""]) + execute(["chmod", "444", ssh_public_key_file]) + execute(["chmod", "600", ssh_identity_file]) with open(ssh_config_file, "w+") as f: # Host (public Gitea URL) is rewritten as an alias to HostName (private ssh URL) f.write( @@ -355,7 +365,7 @@ async def create_ssh_config_if_not_exists(context: AppContext, course, student) await context.api.set_ssh_key("jls-client", public_key) async def clone_repo_if_not_exists(context: AppContext, course, student) -> None: - repo_root = context._compute_repo_root(course["name"]) + repo_root = StudentClassRepo._compute_repo_root(course["name"]) try: # We're just confirming that the repo root is a git repository. # If it is, don't need to clone @@ -396,17 +406,17 @@ def is_repo_populated(): # This block should never fail. If it does, just abort. init_repository(repo_root) await set_git_authentication(context, course, student) - add_remote(UPSTREAM_REMOTE_NAME, master_repository_url, path=repo_root) - add_remote(ORIGIN_REMOTE_NAME, student_repository_url, path=repo_root) + add_remote(StudentClassRepo.UPSTREAM_REMOTE_NAME, master_repository_url, path=repo_root) + add_remote(StudentClassRepo.ORIGIN_REMOTE_NAME, student_repository_url, path=repo_root) @backoff.on_exception(backoff.constant, Exception, interval=2.5, max_time=15) def try_fetch(remote): fetch_repository(remote, path=repo_root) # If either of these reach backoff and fail, just abort - try_fetch(ORIGIN_REMOTE_NAME) - checkout(f"{ MAIN_BRANCH_NAME }", path=repo_root) - try_fetch(UPSTREAM_REMOTE_NAME) + try_fetch(StudentClassRepo.ORIGIN_REMOTE_NAME) + checkout(f"{ StudentClassRepo.MAIN_BRANCH_NAME }", path=repo_root) + try_fetch(StudentClassRepo.UPSTREAM_REMOTE_NAME) @backoff.on_exception(backoff.constant, Exception, interval=2.5, max_time=15) async def mark_as_cloned(): @@ -420,7 +430,7 @@ async def mark_as_cloned(): raise e async def set_git_authentication(context: AppContext, course, student) -> None: - repo_root = context._compute_repo_root(course["name"]).resolve() + repo_root = StudentClassRepo._compute_repo_root(course["name"]).resolve() student_repository_url = student["fork_remote_url"] ssh_config_file = repo_root / ".ssh" / "config" ssh_identity_file = repo_root / ".ssh" / "id_gitea" @@ -489,19 +499,88 @@ async def set_root_folder_permissions(context: AppContext) -> None: ... async def sync_upstream_repository(context: AppContext, course) -> None: - repo_root = context._compute_repo_root(course["name"]) + assignments = await context.api.get_my_assignments() + repo_root = StudentClassRepo._compute_repo_root(course["name"]) + + def backup_file(conflict_path: Path): + print("BACKING UP FILE", conflict_path) + full_conflict_path = repo_root / conflict_path + if full_conflict_path in file_contents: + # Backup the student's changes to a new file. + backup_path = repo_root / Path(f"{ conflict_path }~{ isonow }~backup") + with open(backup_path, "wb+") as f: + f.write(file_contents[full_conflict_path]) + else: + print(str(conflict_path), "deleted locally, cannot create a backup.") + + def move_untracked_files(): + # In case there are no files, we still want to make the dir so no error when deleting later. + untracked_files_dir.mkdir(parents=True, exist_ok=True) + for file in untracked_files: + untracked_path = untracked_files_dir / file + untracked_path.parent.mkdir(parents=True, exist_ok=True) + (repo_root / file).rename(untracked_path) + + def restore_untracked_files(): + # Git refuses to allow you to apply a stash if any untracked changes within the stash exist locally. + # Thus, we have to manually move and then backup untracked files after merging. + for original_file in untracked_files: + full_original_file_path = repo_root / original_file + untracked_path = untracked_files_dir / original_file + + if not full_original_file_path.exists(): + # If the file doesn't exist post-merge, it hasn't been changed at all, and we can just + # move the file back to its original path in the repo. + untracked_path.rename(full_original_file_path) + elif full_original_file_path.read_bytes() != untracked_path.read_bytes(): + # If the file exists post merge, but its content is the exact same, we woudn't need to take any actions. + # The file exists but its content has changed, so backup the old version. + print(f"Couldn't restore untracked file '{ original_file }' as it already exists on HEAD, backing up instead...") + backup_file(original_file) + + # Grab every overwritable path inside the repository. + # Note: we need to this multiple times, since we can only pick up paths that exist on disk. + # If the local head deleted a file, it won't be picked up in the first pass. + # Vice-versa, if the merge head deleted a file, it won't be picked up in the second pass. + def gather_overwritable_paths(): + for assignment in assignments: + for glob_pattern in assignment["overwritable_files"]: + overwritable_paths.update((repo_root / assignment["directory_path"]).glob(glob_pattern)) + + def rename_merge_conflicts(merge_conflicts, source): + conflict_types = { + conflict["path"] : conflict["modification_type"] for conflict in get_modified_paths(path=repo_root) + if conflict["path"] in merge_conflicts + } + for conflict in merge_conflicts: + if repo_root / conflict not in overwritable_paths: + # If the file isn't overwritable, make a backup of it (as long as it's not deleted locally). + print("Encountered non-overwriteable merge conflict", conflict, ". Creating backup...") + backup_file(conflict) + else: + print(f"Detected overwritable merge conflict: '{ conflict }'") + + # Overwrite the file with its incoming version -- resolve the conflict. + if conflict_types[conflict][1] != "D": + git_restore(conflict, source=source, staged=True, worktree=True, path=repo_root) + else: + # If the conflict was deleted on the merge head, git restore won't be able to restore it. + # Instead, just update the index/worktree to also delete the file. + git_rm(conflict, cached=False, path=repo_root) + try: - fetch_repository(UPSTREAM_REMOTE_NAME, path=repo_root) + fetch_repository(StudentClassRepo.UPSTREAM_REMOTE_NAME, path=repo_root) # In case we've pushed directly to the student's repository on the remote for some reason (through Gitea-Assist) - fetch_repository(ORIGIN_REMOTE_NAME, path=repo_root) + fetch_repository(StudentClassRepo.ORIGIN_REMOTE_NAME, path=repo_root) except: print("Fatal: Couldn't fetch remote tracking branches, aborting sync...") + return - checkout(MAIN_BRANCH_NAME, path=repo_root) + checkout(StudentClassRepo.MAIN_BRANCH_NAME, path=repo_root) local_head = get_head_commit_id(path=repo_root) - upstream_head = get_head_commit_id(UPSTREAM_TRACKING_BRANCH, path=repo_root) - merge_branch_name = MERGE_STAGING_BRANCH_NAME.format(local_head[:8], upstream_head[:8]) + upstream_head = get_head_commit_id(StudentClassRepo.UPSTREAM_TRACKING_BRANCH, path=repo_root) + merge_branch_name = StudentClassRepo.MERGE_STAGING_BRANCH_NAME.format(local_head[:8], upstream_head[:8]) if is_ancestor_commit(descendant=local_head, ancestor=upstream_head, path=repo_root): # If the local head is a descendant of the local head, # then any upstream changes have already been merged in. @@ -514,27 +593,70 @@ async def sync_upstream_repository(context: AppContext, course) -> None: # Branch onto the merge branch off the user's head checkout(merge_branch_name, new_branch=True, path=repo_root) + + isonow = datetime.now().isoformat() + # These are relative to the repo root. + file_contents = { path: path.read_bytes() for path in repo_root.rglob("*") if path.is_file() and ".git" not in path.parts } + untracked_files = { + f["path"] for f in get_modified_paths(untracked=True, path=repo_root) + if f["modification_type"] == "??" + } + untracked_files_dir = repo_root / f".untracked-{ isonow }" + overwritable_paths = set() + + gather_overwritable_paths() + # Merge the upstream tracking branch into the temp merge branch try: - print(f"Merging { UPSTREAM_TRACKING_BRANCH } ({ upstream_head[:8] }) --> { MAIN_BRANCH_NAME } ({ local_head[:8] }) on branch { merge_branch_name }") + print(f"Merging { StudentClassRepo.UPSTREAM_TRACKING_BRANCH } ({ upstream_head[:8] }) --> { StudentClassRepo.MAIN_BRANCH_NAME } ({ local_head[:8] }) on branch { merge_branch_name }") + + # We move untracked files because git can't merge them, so it will refuse if a conflict + # would be caused, which we don't want. + move_untracked_files() + + # We have to stash because git refuses to merge if the merge would overwrite local changes. + # NOTE: we don't use git stash --include-untracked because it does not work properly with merge. + stash_changes(path=repo_root) + # Merge the upstream tracking branch into the merge branch - conflicts = git_merge(UPSTREAM_TRACKING_BRANCH, commit=True, path=repo_root) - if len(conflicts) > 0: - raise Exception("Encountered merge conflicts during merge: ", ", ".join(conflicts)) + merge_conflicts = git_merge(StudentClassRepo.UPSTREAM_TRACKING_BRANCH, commit=False, path=repo_root) + gather_overwritable_paths() # pick up paths introduced by the merge head + rename_merge_conflicts(merge_conflicts, source="MERGE_HEAD") # restore conflicts using their incoming version from the MERGE_HEAD + + commit(None, no_edit=True, path=repo_root) + + # After popping, we could have further conflicts between the student's stashed changes and the new local head. + pop_stash(path=repo_root) + stash_conflicts = git_diff_status(diff_filter="U", path=repo_root) + gather_overwritable_paths() # technically, not really necessary since we gather before stashing. + rename_merge_conflicts(stash_conflicts, source="HEAD") except Exception as e: - print("Fatal: Can't merge upstream changes into student repository", e) # Cleanup the merge branch and return to main - abort_merge(path=repo_root) - checkout(MAIN_BRANCH_NAME, path=repo_root) + print("Fatal: Can't merge upstream changes into student repository", e) + # Since we force checkout and then delete the temp merge branch, it doesn't particularly + # matter to us if the merge fails to abort, since we delete the MERGE_HEAD regardless. + try: abort_merge(path=repo_root) + except: + print("(failed to abort merge)") + # if an error occurs after we've already popped, there won't be anything to pop on the stack. + try: pop_stash(path=repo_root) + except: + print("(failed to pop stash, already popped)") + checkout(StudentClassRepo.MAIN_BRANCH_NAME, force=True, path=repo_root) delete_local_branch(merge_branch_name, force=True, path=repo_root) return + + finally: + # It doesn't really matter when we restore these, as long as it happens post-merge. + restore_untracked_files() + shutil.rmtree(untracked_files_dir) - checkout(MAIN_BRANCH_NAME, path=repo_root) + checkout(StudentClassRepo.MAIN_BRANCH_NAME, path=repo_root) # If we successfully merged it, we can go ahead and merge the temp branch into our actual branch try: - print(f"Merging { merge_branch_name } --> { MAIN_BRANCH_NAME }") + print(f"Merging { merge_branch_name } --> { StudentClassRepo.MAIN_BRANCH_NAME }") # Merge the merge staging branch into the actual branch, don't need to commit since fast forward # We don't need to check for conflicts here since the actual branch can now be fast forwarded. git_merge(merge_branch_name, ff_only=True, commit=False, path=repo_root) @@ -542,7 +664,9 @@ async def sync_upstream_repository(context: AppContext, course) -> None: except Exception as e: # Merging from temp to actual branch failed. print(f"Fatal: Failed to merge the merge staging branch into actual branch", e) - abort_merge(path=repo_root) + # Try to abort the merge, if started and unconcluded. + try: abort_merge(path=repo_root) + except: print("(failed to abort)") finally: delete_local_branch(merge_branch_name, force=True, path=repo_root) @@ -586,6 +710,7 @@ def setup_handlers(server_app): ("assignments", AssignmentsHandler), ("course_student", CourseAndStudentHandler), ("submit_assignment", SubmissionHandler), + ("notebook_files", NotebookFilesHandler), ("settings", SettingsHandler) ] diff --git a/eduhelx_jupyterlab_student/student_repo.py b/eduhelx_jupyterlab_student/student_repo.py index 7556c18..0f46dcf 100644 --- a/eduhelx_jupyterlab_student/student_repo.py +++ b/eduhelx_jupyterlab_student/student_repo.py @@ -6,40 +6,61 @@ class NotStudentClassRepositoryException(Exception): pass + +""" Note: this class is naive to the fixed repo path. It is designed for +relative interaction with class repository filepaths WHILE inside the repository. """ class StudentClassRepo: + FIXED_REPO_ROOT = "eduhelx/{}-student" # + ORIGIN_REMOTE_NAME = "origin" + UPSTREAM_REMOTE_NAME = "upstream" + # Local branch + MAIN_BRANCH_NAME = "main" + # We have to do a merge to sync changes. We stage the merge on a separate branch + # to proactively guard against a merge conflict. + MERGE_STAGING_BRANCH_NAME = "__temp__/merge_{}-from-{}" # Formatted with the local head and upstream head commit hashes + UPSTREAM_TRACKING_BRANCH = f"{ UPSTREAM_REMOTE_NAME }/{ MAIN_BRANCH_NAME }" + ORIGIN_TRACKING_BRANCH = f"{ ORIGIN_REMOTE_NAME }/{ MAIN_BRANCH_NAME }" + def __init__(self, course, assignments, current_path): self.course = course self.assignments = assignments self.current_path = os.path.realpath(current_path) - self.repo_root = self._compute_repo_root(self.course, self.current_path) + self.repo_root = self._compute_repo_root(self.course["name"], self.current_path) self.current_assignment = self._compute_current_assignment(self.assignments, self.repo_root, self.current_path) def get_assignment_path(self, assignment): return os.path.join(self.repo_root, assignment["directory_path"]) - @staticmethod - def _compute_repo_root(course, current_path): - try: - master_repo_remote = git.get_remote(name="upstream", path=current_path) - repo_root = os.path.realpath( - git.get_repo_root(path=current_path) - ) - if master_repo_remote != course["master_remote_url"]: + def get_protected_file_paths(self, assignment) -> list[Path]: + files = [] + for glob_pattern in assignment["protected_files"]: + files += self.get_assignment_path(assignment).glob(glob_pattern) + return files + + + + @classmethod + def _compute_repo_root(cls, course_name, current_path: str | None = None): + """ Validates that user is in the repository root if current_path is provided """ + repo_root = Path(cls.FIXED_REPO_ROOT.format(course_name.replace(" ", "_"))) + if current_path is not None: + try: + Path(os.path.realpath(current_path)).relative_to(os.path.realpath(repo_root)) + except ValueError: raise NotStudentClassRepositoryException() - return repo_root - except InvalidGitRepositoryException as e: - raise e + return repo_root @staticmethod def _compute_current_assignment(assignments, repo_root, current_path): current_assignment = None + current_path_abs = Path(current_path).resolve() for assignment in assignments: assignment_path = Path(os.path.join( repo_root, assignment["directory_path"] - )) - if assignment_path == Path(current_path) or assignment_path in Path(current_path).parents: + )).resolve() + if assignment_path == current_path_abs or assignment_path in current_path_abs.parents: current_assignment = assignment break diff --git a/package.json b/package.json index 4942459..5316df9 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@jupyterlab/coreutils": "^6.0.0", "@jupyterlab/filebrowser": "^4.0.11", "@jupyterlab/services": "^7.0.0", + "@jupyterlab/settingregistry": "^4.2.4", "@jupyterlab/ui-components": "^4.0.11", "@lumino/commands": "^2.0.0", "@lumino/coreutils": "^2.1.2", diff --git a/src/api/api-responses.ts b/src/api/api-responses.ts index bee3075..85e9755 100644 --- a/src/api/api-responses.ts +++ b/src/api/api-responses.ts @@ -53,8 +53,11 @@ export interface AssignmentResponse { absolute_directory_path: string master_notebook_path: string student_notebook_path: string + protected_files: string[] + overwritable_files: string[] max_attempts: number | null current_attempts: number + grader_question_feedback: boolean created_date: string available_date: string | null adjusted_available_date: string | null diff --git a/src/api/api.ts b/src/api/api.ts index 921af94..0eeb112 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -24,6 +24,17 @@ export interface GetStudentAndCourseResponse { course: ICourse } +export interface NotebookFilesResponse { + notebooks: { [assignmentId: string]: string[] } +} + +export async function listNotebookFiles(): Promise { + const data = await requestAPI(`/notebook_files`, { + method: 'GET' + }) + return data +} + export async function getStudentAndCourse(): Promise { const { student, course } = await requestAPI<{ student: StudentResponse diff --git a/src/api/assignment.ts b/src/api/assignment.ts index 1bdfa6a..bc26195 100644 --- a/src/api/assignment.ts +++ b/src/api/assignment.ts @@ -12,8 +12,11 @@ export interface IAssignment { readonly absoluteDirectoryPath: string readonly masterNotebookPath: string readonly studentNotebookPath: string + readonly protectedFiles: string[] + readonly overwritableFiles: string[] readonly maxAttempts: number | null readonly currentAttempts: number + readonly graderQuestionFeedback: boolean readonly createdDate: Date readonly adjustedAvailableDate: Date | null readonly adjustedDueDate: Date | null @@ -51,8 +54,11 @@ export class Assignment implements IAssignment { private _absoluteDirectoryPath: string, private _masterNotebookPath: string, private _studentNotebookPath: string, + private _protectedFiles: string[], + private _overwritableFiles: string[], private _maxAttempts: number | null, private _currentAttempts: number, + private _graderQuestionFeedback: boolean, private _createdDate: Date, private _adjustedAvailableDate: Date | null, private _adjustedDueDate: Date | null, @@ -77,8 +83,11 @@ export class Assignment implements IAssignment { get absoluteDirectoryPath() { return this._absoluteDirectoryPath } get masterNotebookPath() { return this._masterNotebookPath } get studentNotebookPath() { return this._studentNotebookPath } + get protectedFiles() { return this._protectedFiles } + get overwritableFiles() { return this._overwritableFiles } get maxAttempts() { return this._maxAttempts } get currentAttempts() { return this._currentAttempts } + get graderQuestionFeedback() { return this._graderQuestionFeedback } get createdDate() { return this._createdDate } get adjustedAvailableDate() { return this._adjustedAvailableDate } get adjustedDueDate() { return this._adjustedDueDate } @@ -109,8 +118,11 @@ export class Assignment implements IAssignment { data.absolute_directory_path, data.master_notebook_path, data.student_notebook_path, + data.protected_files, + data.overwritable_files, data.max_attempts, data.current_attempts, + data.grader_question_feedback, new Date(data.created_date), data.adjusted_available_date ? new Date(data.adjusted_available_date) : null, data.adjusted_due_date ? new Date(data.adjusted_due_date) : null, diff --git a/src/contexts/assignment-context.tsx b/src/contexts/assignment-context.tsx index 89bf348..1060eb2 100644 --- a/src/contexts/assignment-context.tsx +++ b/src/contexts/assignment-context.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, ReactNode, useState, useMemo, useEffect } from 'react' +import React, { createContext, useContext, ReactNode, useState, useMemo, useEffect, useCallback } from 'react' import { IChangedArgs, URLExt } from '@jupyterlab/coreutils' import { ServerConnection } from '@jupyterlab/services' import { FileBrowserModel, IDefaultFileBrowser } from '@jupyterlab/filebrowser' @@ -6,7 +6,11 @@ import { showDialog, Dialog } from '@jupyterlab/apputils' import { Button } from '@jupyterlab/ui-components' import { useSnackbar } from './snackbar-context' import { IEduhelxSubmissionModel } from '../tokens' -import { IAssignment, IStudent, ICurrentAssignment, ICourse, getAssignmentsPolled, GetAssignmentsResponse, getStudentAndCoursePolled, getStudentAndCourse, getAssignments } from '../api' +import { IAssignment, IStudent, ICurrentAssignment, ICourse, getAssignmentsPolled, GetAssignmentsResponse, getStudentAndCoursePolled, getStudentAndCourse, getAssignments, listNotebookFiles } from '../api' + +interface StudentNotebookExists { + (assignment: IAssignment, directoryPath?: string | undefined): boolean +} interface IAssignmentContext { assignments: IAssignment[] | null | undefined @@ -15,6 +19,7 @@ interface IAssignmentContext { course: ICourse | undefined path: string | null loading: boolean + studentNotebookExists: StudentNotebookExists } interface IAssignmentProviderProps { @@ -29,6 +34,7 @@ const WEBSOCKET_URL = URLExt.join( "ws" ) const WEBSOCKET_REOPEN_DELAY = 1000 +const POLL_DELAY = 15000 export const AssignmentContext = createContext(undefined) @@ -40,14 +46,22 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide const [assignments, setAssignments] = useState(undefined) const [student, setStudent] = useState(undefined) const [course, setCourse] = useState(undefined) + const [notebookFiles, setNotebookFiles] = useState<{ [key: string]: string[] }|undefined>(undefined) const [ws, setWs] = useState(() => new WebSocket(WEBSOCKET_URL)) const loading = useMemo(() => ( currentAssignment === undefined || assignments === undefined || student === undefined || - course === undefined - ), [currentAssignment, assignments, student, course]) + course === undefined || + notebookFiles === undefined + ), [currentAssignment, assignments, student, course, notebookFiles]) + + const studentNotebookExists = useCallback((assignment: IAssignment, studentNotebookPath?: string | undefined) => { + if (!notebookFiles) return false + if (studentNotebookPath === undefined) studentNotebookPath = assignment.studentNotebookPath + return notebookFiles[assignment.id].some((file) => file === studentNotebookPath) + }, [notebookFiles]) useEffect(() => { const triggerReconnect = () => { @@ -92,6 +106,7 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide setCurrentAssignment(undefined) let cancelled = false + let timeoutId: number | undefined = undefined const poll = async () => { let value if (currentPath !== null) { @@ -113,11 +128,12 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide setAssignments(undefined) setCurrentAssignment(undefined) } - setTimeout(poll, 2500) + timeoutId = window.setTimeout(poll, POLL_DELAY) } - setTimeout(poll, 2500) + poll() return () => { cancelled = true + window.clearTimeout(timeoutId) } }, [currentPath]) @@ -126,6 +142,7 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide setStudent(undefined) let cancelled = false + let timeoutId: number | undefined = undefined const poll = async () => { let value try { @@ -145,11 +162,45 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide setCourse(undefined) setStudent(undefined) } - setTimeout(poll, 2500) + timeoutId = window.setTimeout(poll, POLL_DELAY) + } + poll() + return () => { + cancelled = true + window.clearTimeout(timeoutId) + } + }, []) + + useEffect(() => { + setNotebookFiles(undefined) + + let cancelled = false + let timeoutId: number | undefined = undefined + const poll = async () => { + let value + try { + value = await listNotebookFiles() + } catch (e: any) { + console.error(e) + snackbar.open({ + type: 'warning', + message: 'Failed to pull course data...' + }) + } + if (cancelled) return + if (value !== undefined) { + setNotebookFiles(value.notebooks) + } else { + setNotebookFiles(undefined) + } + // We don't use POLL_DELAY for fetching notebook files, since this needs to be reflected more rapidly + // to the user and also doesn't involve any API calls, only scanning the directory for ipynb files. + timeoutId = window.setTimeout(poll, 2500) } - setTimeout(poll, 2500) + poll() return () => { cancelled = true + window.clearTimeout(timeoutId) } }, []) @@ -160,7 +211,8 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide student, course, path: currentPath, - loading + loading, + studentNotebookExists }}> { children } diff --git a/src/handler.ts b/src/handler.ts index 7ad1b90..b9ba482 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -27,18 +27,16 @@ export async function requestAPI( throw new ServerConnection.NetworkError(error as any) } - let data: any = await response.text() - - if (data.length > 0) { - try { - data = JSON.parse(data) - } catch (error) { - console.log('Not a JSON response body.', response) - } + let data + try { + // Clone so we don't read the response body in the event of an error (we return the response). + data = await response.clone().json() + } catch (error) { + console.log('Not a JSON response body.', response) } if (!response.ok) { - throw new ServerConnection.ResponseError(response, data.message || data) + throw new ServerConnection.ResponseError(response, data?.message || data) } return data diff --git a/src/index.ts b/src/index.ts index 27abbf4..32bff9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,12 +12,14 @@ import { AssignmentWidget } from './widgets' import { EduhelxSubmissionModel } from './model' import { submissionIcon } from './style/icons' import { IFileBrowserFactory } from '@jupyterlab/filebrowser' +import { ISettingRegistry } from '@jupyterlab/settingregistry' async function activate ( app: JupyterFrontEnd, fileBrowser: IDefaultFileBrowser, restorer: ILayoutRestorer, shell: ILabShell, + settingRegistry: ISettingRegistry ) { let serverSettings: IServerSettings try { @@ -32,6 +34,13 @@ async function activate ( return } + Promise.all([app.restored, fileBrowser.model.restored]).then(() => { + // A couple things are required to get jupyter to show dotfiles/~ files/etc. + // This browser setting needs to be enabled for filebrowser, as well as + // the server setting ContentsManager.allow_hidden=True + settingRegistry.set("@jupyterlab/filebrowser-extension:browser", "showHiddenFiles", true) + }) + // const model = new EduhelxSubmissionModel() // Promise.all([app.restored, fileBrowser.model.restored]).then(() => { // model.currentPath = fileBrowser.model.path @@ -61,6 +70,7 @@ const plugin: JupyterFrontEndPlugin = { IDefaultFileBrowser, ILayoutRestorer, ILabShell, + ISettingRegistry ], activate };