Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge renames #24

Merged
merged 8 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 171 additions & 46 deletions eduhelx_jupyterlab_student/handlers.py

Large diffs are not rendered by default.

49 changes: 35 additions & 14 deletions eduhelx_jupyterlab_student/student_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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" # <class_name>
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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/api/api-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ export interface GetStudentAndCourseResponse {
course: ICourse
}

export interface NotebookFilesResponse {
notebooks: { [assignmentId: string]: string[] }
}

export async function listNotebookFiles(): Promise<NotebookFilesResponse> {
const data = await requestAPI<NotebookFilesResponse>(`/notebook_files`, {
method: 'GET'
})
return data
}

export async function getStudentAndCourse(): Promise<GetStudentAndCourseResponse> {
const { student, course } = await requestAPI<{
student: StudentResponse
Expand Down
12 changes: 12 additions & 0 deletions src/api/assignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 }
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { Fragment, useMemo } from 'react'
import React, { Fragment, useCallback, useMemo } from 'react'
import { Tooltip } from 'antd'
import { FormHelperText, Input } from '@material-ui/core'
import { ArrowBackSharp } from '@material-ui/icons'
import moment from 'moment'
import pluralize from 'pluralize'
import { ArrowBackSharp } from '@material-ui/icons'
import { assignmentInfoClass, assignmentInfoSectionClass, assignmentInfoSectionHeaderClass, assignmentInfoSectionWarningClass, assignmentNameClass, tagClass } from './style'
import { useAssignment } from '../../../contexts'
import { useAssignment, useCommands, } from '../../../contexts'
import { DateFormat } from '../../../utils'

const MS_IN_HOURS = 3.6e6
Expand All @@ -13,7 +14,8 @@ interface AssignmentInfoProps {
}

export const AssignmentInfo = ({ }: AssignmentInfoProps) => {
const { assignment, student, course } = useAssignment()!
const { assignment, student, course, studentNotebookExists } = useAssignment()!
const commands = useCommands()
if (!student || !assignment || !course) return null

const hoursUntilDue = useMemo(() => (
Expand Down Expand Up @@ -99,6 +101,16 @@ export const AssignmentInfo = ({ }: AssignmentInfoProps) => {
else return `You are allowed to submit ${ remainingAttempts }${ assignment.currentAttempts > 0 ? ' more' : ''} ${ pluralize("time", remainingAttempts) }`
}, [assignment])

const studentNotebookInvalid = useMemo(() => (
!studentNotebookExists(assignment)
), [studentNotebookExists, assignment])

const openStudentNotebook = useCallback(async () => {
if (!commands || !assignment) return
const fullNotebookPath = assignment.absoluteDirectoryPath + "/" + assignment.studentNotebookPath
commands.execute('docmanager:open', { path: fullNotebookPath })
}, [commands, assignment])

return (
<div className={ assignmentInfoClass }>
<div>
Expand Down Expand Up @@ -132,7 +144,7 @@ export const AssignmentInfo = ({ }: AssignmentInfoProps) => {
<span>{ course.instructors.map((ins) => ins.name).join(", ") }</span>
</div>
<div className={ assignmentInfoSectionClass }>
<h5 className={ assignmentInfoSectionHeaderClass }>Due date</h5>
<h5 className={ assignmentInfoSectionHeaderClass }>Due Date</h5>
<div>
{ assignment.isCreated ? (
new DateFormat(assignment.adjustedDueDate!).toBasicDatetime()
Expand Down Expand Up @@ -167,6 +179,32 @@ export const AssignmentInfo = ({ }: AssignmentInfoProps) => {
</div>
</div>
) }
<div className={ assignmentInfoSectionClass } style={{ marginTop: 0 }}>
<h5 className={ assignmentInfoSectionHeaderClass }>
Assignment Notebook
</h5>
<div style={{ width: "100%" }}>
{ !studentNotebookInvalid ? (
<Fragment>
<Input
readOnly
value={ assignment.studentNotebookPath }
inputProps={{ style: { height: 32 } }}
style={{ width: "100%" }}
/>
<FormHelperText style={{ color: "#1976d2" }}>
<a onClick={ openStudentNotebook } style={{ cursor: "pointer" }}>
Open notebook
</a>
</FormHelperText>
</Fragment>
) : (
<span>
Not published yet
</span>
) }
</div>
</div>
</div>
)
}
70 changes: 61 additions & 9 deletions src/contexts/assignment-context.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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'
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
Expand All @@ -15,6 +19,7 @@ interface IAssignmentContext {
course: ICourse | undefined
path: string | null
loading: boolean
studentNotebookExists: StudentNotebookExists
}

interface IAssignmentProviderProps {
Expand All @@ -29,6 +34,7 @@ const WEBSOCKET_URL = URLExt.join(
"ws"
)
const WEBSOCKET_REOPEN_DELAY = 1000
const POLL_DELAY = 15000

export const AssignmentContext = createContext<IAssignmentContext|undefined>(undefined)

Expand All @@ -40,14 +46,22 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide
const [assignments, setAssignments] = useState<IAssignment[]|null|undefined>(undefined)
const [student, setStudent] = useState<IStudent|undefined>(undefined)
const [course, setCourse] = useState<ICourse|undefined>(undefined)
const [notebookFiles, setNotebookFiles] = useState<{ [key: string]: string[] }|undefined>(undefined)
const [ws, setWs] = useState<WebSocket>(() => 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 = () => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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])

Expand All @@ -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 {
Expand All @@ -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)
}
}, [])

Expand All @@ -160,7 +211,8 @@ export const AssignmentProvider = ({ fileBrowser, children }: IAssignmentProvide
student,
course,
path: currentPath,
loading
loading,
studentNotebookExists
}}>
{ children }
</AssignmentContext.Provider>
Expand Down
Loading
Loading