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

JLS Upgrades #5

Merged
merged 16 commits into from
Mar 19, 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
1 change: 0 additions & 1 deletion .env

This file was deleted.

14 changes: 13 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
GRADER_API_URL=http://localhost:8000
# Base URL of the Grader API
GRADER_API_URL=http://localhost:8000
# User's onyen
USER_ONYEN=myonyen
# User's password
USER_AUTOGEN_PASSWORD=mypassword
# How far ahead of time to refresh the user's access token
# (proactively refreshing deals with issues such as latency and clock sync)
JWT_REFRESH_LEEWAY_SECONDS=60
# How long to keep long-polling connections alive before dropping the client (Jupyter frontend).
LONG_POLLING_TIMEOUT_SECONDS=60
# For polling that depends on unobservable data, how long to sleep in between data fetches.
LONG_POLLING_SLEEP_INTERVAL_SECONDS=5
4 changes: 4 additions & 0 deletions eduhelx_jupyterlab_student/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ class Config:
# How far ahead of time the API should refresh the access token
# (proactively refreshing using a buffer deals with issues such as latency and clock sync)
JWT_REFRESH_LEEWAY_SECONDS: int = 60
# How long to keep long-polling connections alive before dropping the client.
LONG_POLLING_TIMEOUT_SECONDS: int = 60
# For polling that depends on unobservable data, how long to sleep in between data fetches.
LONG_POLLING_SLEEP_INTERVAL_SECONDS: int = 5

"""
Map environment variables to class fields according to these rules:
Expand Down
105 changes: 85 additions & 20 deletions eduhelx_jupyterlab_student/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@
import tempfile
import shutil
import tornado
import time
import asyncio
from jupyter_server.base.handlers import APIHandler
from jupyter_server.utils import url_path_join
from pathlib import Path
from collections.abc import Iterable
from .config import ExtensionConfig
from eduhelx_utils.git import (
InvalidGitRepositoryException,
clone_repository,
get_tail_commit_id, get_repo_name, add_remote,
stage_files, commit, push, get_commit_info
stage_files, commit, push, get_commit_info,
get_modified_paths
)
from eduhelx_utils.api import Api
from .student_repo import StudentClassRepo, NotStudentClassRepositoryException
Expand Down Expand Up @@ -123,20 +127,60 @@ async def post(self):

self.finish(json.dumps(os.path.join("/", frontend_cloned_path)))

class CourseAndStudentHandler(BaseHandler):
class PollCourseStudentHandler(BaseHandler):
@tornado.web.authenticated
async def get(self):
# If this is the first poll the client has made, the `current_value` argument will not be present.
# Note: we are not deserializing `current_value`; it is a JSON-serialized string.
current_value: str | None = None
if "current_value" in self.request.arguments:
current_value = self.get_argument("current_value")

start = time.time()
while (elapsed := time.time() - start) < self.config.LONG_POLLING_TIMEOUT_SECONDS:
new_value = await CourseAndStudentHandler.get_value(self)
if new_value != current_value:
self.finish(new_value)
return
asyncio.sleep(self.config.LONG_POLLING_SLEEP_INTERVAL_SECONDS)
ptlharit2 marked this conversation as resolved.
Show resolved Hide resolved

self.finish(new_value)

class CourseAndStudentHandler(BaseHandler):
async def get_value(self):
student = await self.api.get_my_user()
course = await self.api.get_course()
self.finish(json.dumps({
return json.dumps({
"student": student,
"course": course
}))
})

@tornado.web.authenticated
async def get(self):
self.finish(await self.get_value())

class AssignmentsHandler(BaseHandler):
class PollAssignmentsHandler(BaseHandler):
@tornado.web.authenticated
async def get(self):
current_path: str = self.get_argument("path")
# If this is the first poll the client has made, the `current_value` argument will not be present.
# Note: we are not deserializing `current_value`; it is a JSON-serialized string.
current_value: str | None = None
if "current_value" in self.request.arguments:
current_value = self.get_argument("current_value")

start = time.time()
while (elapsed := time.time() - start) < self.config.LONG_POLLING_TIMEOUT_SECONDS:
new_value = await AssignmentsHandler.get_value(self, current_path)
if new_value != current_value:
self.finish(new_value)
return
asyncio.sleep(self.config.LONG_POLLING_SLEEP_INTERVAL_SECONDS)

self.finish(new_value)

class AssignmentsHandler(BaseHandler):
async def get_value(self, current_path: str):
current_path_abs = os.path.realpath(current_path)

student = await self.api.get_my_user()
Expand All @@ -145,14 +189,13 @@ async def get(self):

value = {
"current_assignment": None,
"assignments": None
"assignments": None,
}

try:
student_repo = StudentClassRepo(course, assignments, current_path_abs)
except Exception:
self.finish(json.dumps(value))
return
return json.dumps(value)

# Add absolute path to assignment so that the frontend
# extension knows how to open the assignment without having
Expand All @@ -166,21 +209,40 @@ async def get(self):
cwd
)
# The cwd is the root in the frontend, so treat the path as such.
# NOTE: IMPORTANT: this field is NOT absolute on the server. It's only the absolute path for the webapp.
assignment["absolute_directory_path"] = os.path.join("/", rel_assignment_path)
value["assignments"] = assignments

# The student is in their repo, but we still need to check if they're actually in an assignment directory.
current_assignment = student_repo.current_assignment
if current_assignment is not None:
submissions = await self.api.get_my_submissions(current_assignment["id"])
for submission in submissions:
submission["commit"] = get_commit_info(submission["commit_id"], path=student_repo.repo_root)
current_assignment["submissions"] = submissions

value["current_assignment"] = current_assignment
self.finish(json.dumps(value))
else:
self.finish(json.dumps(value))
# The student is in their repo, but we still need to check if they're actually in an assignment directory.
if current_assignment is None:
# If user is not in an assignment, we're done. Just leave current_assignment as None.
return json.dumps(value)

submissions = await self.api.get_my_submissions(current_assignment["id"])
for submission in submissions:
submission["commit"] = get_commit_info(submission["commit_id"], path=student_repo.repo_root)
current_assignment["submissions"] = submissions
current_assignment["staged_changes"] = []
for modified_path in get_modified_paths(path=student_repo.repo_root):
full_modified_path = Path(student_repo.repo_root) / modified_path["path"]
abs_assn_path = Path(student_repo.repo_root) / assignment["directory_path"]
try:
path_relative_to_assn = full_modified_path.relative_to(abs_assn_path)
modified_path["path_from_repo"] = modified_path["path"]
modified_path["path_from_assn"] = str(path_relative_to_assn)
current_assignment["staged_changes"].append(modified_path)
ptlharit2 marked this conversation as resolved.
Show resolved Hide resolved
except ValueError:
# This path is not part of the current assignment directory
pass

value["current_assignment"] = current_assignment
return json.dumps(value)

@tornado.web.authenticated
async def get(self):
current_path: str = self.get_argument("path")
self.finish(await self.get_value(current_path))


class SubmissionHandler(BaseHandler):
Expand Down Expand Up @@ -256,14 +318,17 @@ def setup_handlers(server_app):
base_url = web_app.settings["base_url"]
handlers = [
("assignments", AssignmentsHandler),
(("assignments", "poll"), PollAssignmentsHandler),
("course_student", CourseAndStudentHandler),
(("course_student", "poll"), PollCourseStudentHandler),
("submit_assignment", SubmissionHandler),
("clone_student_repository", CloneStudentRepositoryHandler),
("settings", SettingsHandler)
]

handlers_with_path = [
(
url_path_join(base_url, "jupyterlab-eduhelx-submission", uri),
url_path_join(base_url, "eduhelx-jupyterlab-student", *(uri if not isinstance(uri, str) else [uri])),
handler
) for (uri, handler) in handlers
]
Expand Down
Loading
Loading