diff --git a/.copier-answers.yml b/.copier-answers.yml index 6ee05b6..d6e4919 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,20 +1,15 @@ # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY -_commit: v4.2.4 +_commit: v4.3.5 _src_path: https://github.com/jupyterlab/extension-template author_email: datalab@tuwien.ac.at -author_name: Florian Jaeger -data_format: string -file_extension: '' +author_name: Florian Jaeger, Marijana Petojevic, Matthias Matt has_binder: true has_settings: true kind: server labextension_name: grader-labextension -mimetype: '' -mimetype_name: '' project_short_description: Grader Labextension is a JupyterLab extension to enable automatic grading of assignment notebooks. python_name: grader_labextension -repository: https://github.com/TU-Wien-dataLAB/Grader-Labextension +repository: https://github.com/TU-Wien-dataLAB/grader-labextension test: true -viewer_name: '' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d45143..0fc57f2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,4 +44,4 @@ jobs: retention-days: 1 path: | ./dist - !./dist/**/*.md \ No newline at end of file + !./dist/**/*.md diff --git a/.github/workflows/management_issue_assign.yml b/.github/workflows/management_issue_assign.yml deleted file mode 100644 index 897131b..0000000 --- a/.github/workflows/management_issue_assign.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: "Project Management Issue Assign Actions" - -on: - issues: - types: - - assigned - -jobs: - send_issue_to_management_tool: - runs-on: ubuntu-latest - steps: - - name: View issue information - env: - TITLE: ${{ github.event.issue.title }} - BODY: ${{ github.event.issue.body }} - NUMBER: ${{ github.event.issue.number }} - URL: ${{ github.event.issue.html_url }} - ASSIGNEE: ${{ github.event.issue.assignee.login }} - run: | - echo "Issue title: $TITLE" - echo "Issue body: $BODY" - echo "Issue number: $NUMBER" - echo "Issue url: $URL" - echo "Issue assignee: $ASSIGNEE" - - - name: Send issue to GitLab CI - env: - TITLE: ${{ github.event.issue.title }} - BODY: ${{ github.event.issue.body }} - run: | - curl -X POST --fail \ - -F token=${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} \ - -F "ref=main" \ - -F "variables[ACTION]=ASSIGN" \ - -F "variables[TITLE]=$TITLE" \ - -F "variables[DESCRIPTION]=$BODY" \ - -F "variables[ASSIGNEE]=$ASSIGNEE" \ - -F "variables[URL]=$URL" \ - -F "variables[SOURCE]=github" \ - ${{ secrets.GITLAB_CI_TRIGGER_URL }} diff --git a/.github/workflows/management_issue_closure.yml b/.github/workflows/management_issue_closure.yml deleted file mode 100644 index 334d7c2..0000000 --- a/.github/workflows/management_issue_closure.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: "Project Management Issues Closure Actions" - -on: - issues: - types: - - closed - -jobs: - send_issue_to_management_tool: - runs-on: ubuntu-latest - steps: - - name: View issue information - env: - TITLE: ${{ github.event.issue.title }} - BODY: ${{ github.event.issue.body }} - NUMBER: ${{ github.event.issue.number }} - URL: ${{ github.event.issue.html_url }} - run: | - echo "Issue title: $TITLE" - echo "Issue body: $BODY" - echo "Issue number: $NUMBER" - echo "Issue url: $URL" - - - name: Send issue to GitLab CI - env: - TITLE: ${{ github.event.issue.title }} - BODY: ${{ github.event.issue.body }} - run: | - curl -X POST --fail \ - -F token=${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} \ - -F "ref=main" \ - -F "variables[ACTION]=CLOSE" \ - -F "variables[TITLE]=$TITLE" \ - -F "variables[DESCRIPTION]=$BODY" \ - -F "variables[URL]=$URL" \ - -F "variables[SOURCE]=github" \ - ${{ secrets.GITLAB_CI_TRIGGER_URL }} diff --git a/.github/workflows/management_issue_creation.yml b/.github/workflows/management_issue_creation.yml deleted file mode 100644 index 7ff884e..0000000 --- a/.github/workflows/management_issue_creation.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: "Project Management Issue Creation Actions" - -on: - issues: - types: - - opened - - reopened - -jobs: - send_issue_to_management_tool: - runs-on: ubuntu-latest - steps: - - name: View issue information - env: - TITLE: ${{ github.event.issue.title }} - BODY: ${{ github.event.issue.body }} - NUMBER: ${{ github.event.issue.number }} - URL: ${{ github.event.issue.html_url }} - run: | - echo "Issue title: $TITLE" - echo "Issue body: $BODY" - echo "Issue number: $NUMBER" - echo "Issue url: $URL" - - - name: Send issue to GitLab CI - env: - TITLE: ${{ github.event.issue.title }} - BODY: ${{ github.event.issue.body }} - run: | - curl -X POST --fail \ - -F token=${{ secrets.GITLAB_CI_TRIGGER_TOKEN }} \ - -F "ref=main" \ - -F "variables[ACTION]=OPEN" \ - -F "variables[TITLE]=$TITLE" \ - -F "variables[DESCRIPTION]=$BODY" \ - -F "variables[URL]=$URL" \ - -F "variables[SOURCE]=github" \ - ${{ secrets.GITLAB_CI_TRIGGER_URL }} diff --git a/.gitignore b/.gitignore index cc36381..bbaf62c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.vscode/ *.bundle.* lib/ node_modules/ @@ -15,6 +16,11 @@ grader_labextension/_version.py ui-tests/test-results/ ui-tests/playwright-report/ + +jupyterhub_cookie_secret +jupyterhub-proxy.pid +jupyterhub.sqlite + # Created by https://www.gitignore.io/api/python # Edit at https://www.gitignore.io/?templates=python diff --git a/Dockerfile b/Dockerfile index f56f453..533de83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ ARG REGISTRY=quay.io ARG OWNER=jupyter -ARG BASE_CONTAINER=$REGISTRY/$OWNER/datascience-notebook +ARG BASE_CONTAINER=$REGISTRY/$OWNER/datascience-notebook:latest FROM $BASE_CONTAINER USER root @@ -18,10 +18,11 @@ RUN apt-get update &&\ RUN apt-get clean && \ rm -rf /var/lib/apt/lists/* -COPY ./ /Grader-Labextension +COPY ./ /grader-labextension -RUN python3 -m pip install /Grader-Labextension -RUN rm -rf /Grader-Labextension +RUN mamba install nodejs +RUN python3 -m pip install /grader-labextension +RUN rm -rf /grader-labextension WORKDIR /home/jovyan diff --git a/LICENSE b/LICENSE index a699b43..1e2b20c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2024, Florian Jaeger +Copyright (c) 2024, Florian Jaeger, Marijana Petojevic, Matthias Matt All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 92248d2..4c8826d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # grader_labextension -[![Github Actions Status](https://github.com/TU-Wien-dataLAB/Grader-Labextension/workflows/Build/badge.svg)](https://github.com/TU-Wien-dataLAB/Grader-Labextension/actions/workflows/build.yml)[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/TU-Wien-dataLAB/Grader-Labextension/main?urlpath=lab) +[![Github Actions Status](https://github.com/TU-Wien-dataLAB/grader-labextension/workflows/Build/badge.svg)](https://github.com/TU-Wien-dataLAB/grader-labextension/actions/workflows/build.yml) +[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/TU-Wien-dataLAB/grader-labextension/main?urlpath=lab) + + Grader Labextension is a JupyterLab extension to enable automatic grading of assignment notebooks. This extension is composed of a Python package named `grader_labextension` diff --git a/RELEASE.md b/RELEASE.md index e7238e9..8f62068 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -58,52 +58,21 @@ npm publish --access public ## Automated releases with the Jupyter Releaser -The extension repository should already be compatible with the Jupyter Releaser. - -Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) for more information. +The extension repository should already be compatible with the Jupyter Releaser. But +the GitHub repository and the package managers need to be properly set up. Please +follow the instructions of the Jupyter Releaser [checklist](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_repo.html). Here is a summary of the steps to cut a new release: -- Add tokens to the [Github Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) in the repository: - - `ADMIN_GITHUB_TOKEN` (with "public_repo" and "repo:status" permissions); see the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token) - - `NPM_TOKEN` (with "automation" permission); see the [documentation](https://docs.npmjs.com/creating-and-viewing-access-tokens) -- Set up PyPI - -Using PyPI trusted publisher (modern way) - -- Set up your PyPI project by [adding a trusted publisher](https://docs.pypi.org/trusted-publishers/adding-a-publisher/) - - The _workflow name_ is `publish-release.yml` and the _environment_ should be left blank. -- Ensure the publish release job as `permissions`: `id-token : write` (see the [documentation](https://docs.pypi.org/trusted-publishers/using-a-publisher/)) - - - -Using PyPI token (legacy way) - -- If the repo generates PyPI release(s), create a scoped PyPI [token](https://packaging.python.org/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/#saving-credentials-on-github). We recommend using a scoped token for security reasons. - -- You can store the token as `PYPI_TOKEN` in your fork's `Secrets`. - - - Advanced usage: if you are releasing multiple repos, you can create a secret named `PYPI_TOKEN_MAP` instead of `PYPI_TOKEN` that is formatted as follows: - - ```text - owner1/repo1,token1 - owner2/repo2,token2 - ``` - - If you have multiple Python packages in the same repository, you can point to them as follows: - - ```text - owner1/repo1/path/to/package1,token1 - owner1/repo1/path/to/package2,token2 - ``` - - - - Go to the Actions panel - Run the "Step 1: Prep Release" workflow - Check the draft changelog - Run the "Step 2: Publish Release" workflow +> [!NOTE] +> Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html) +> for more information. + ## Publishing to `conda-forge` If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html diff --git a/binder/db.sql b/binder/db.sql new file mode 100644 index 0000000..e61375d --- /dev/null +++ b/binder/db.sql @@ -0,0 +1,139 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE alembic_version ( + version_num VARCHAR(32) NOT NULL, + CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) +); +INSERT INTO alembic_version VALUES('a0718dae969d'); +CREATE TABLE lecture ( + id INTEGER NOT NULL, + name VARCHAR(255), + code VARCHAR(255), + state VARCHAR(8) NOT NULL, + deleted VARCHAR(7) DEFAULT 'active' NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE (code) +); +INSERT INTO lecture VALUES(1,'lect1','lect1','active','active','2024-11-04 13:11:45.883175','2024-11-04 13:11:45.883180'); +CREATE TABLE assignment ( + id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(5) DEFAULT 'user' NOT NULL, + lectid INTEGER, + duedate DATETIME, + automatic_grading VARCHAR(10) DEFAULT 'unassisted' NOT NULL, + points DECIMAL(10, 3) NOT NULL, + status VARCHAR(8), + deleted VARCHAR(7) DEFAULT 'active' NOT NULL, + max_submissions INTEGER, + allow_files BOOLEAN NOT NULL, + properties TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, settings TEXT DEFAULT '{}' NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(lectid) REFERENCES lecture (id), + CONSTRAINT u_name_in_lect UNIQUE (name, lectid, deleted) +); +CREATE TABLE takepart ( + username VARCHAR(255) NOT NULL, + lectid INTEGER NOT NULL, + role VARCHAR(255) NOT NULL, + PRIMARY KEY (username, lectid), + FOREIGN KEY(lectid) REFERENCES lecture (id), + FOREIGN KEY(username) REFERENCES user (name) +); +INSERT INTO takepart VALUES('admin',1,'instructor'); +INSERT INTO takepart VALUES('instructor',1,'instructor'); +INSERT INTO takepart VALUES('student',1,'student'); +CREATE TABLE IF NOT EXISTS "group" ( + id INTEGER NOT NULL, + name VARCHAR(255) NOT NULL, + lectid INTEGER NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(lectid) REFERENCES lecture (id) +); +CREATE TABLE partof ( + username VARCHAR(255) NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY (username, group_id), + FOREIGN KEY(group_id) REFERENCES "group" (id), + FOREIGN KEY(username) REFERENCES user (name) +); +CREATE TABLE submission ( + id INTEGER NOT NULL, + date DATETIME, + auto_status VARCHAR(20) NOT NULL, + manual_status VARCHAR(15) NOT NULL, + edited BOOLEAN, + score DECIMAL(10, 3), + assignid INTEGER, + username VARCHAR(255), + commit_hash VARCHAR(40) NOT NULL, + updated_at DATETIME NOT NULL, grading_score DECIMAL(10, 3), score_scaling DECIMAL(10, 3) DEFAULT '1.0' NOT NULL, feedback_status VARCHAR(17) DEFAULT 'not_generated' NOT NULL, deleted VARCHAR(7) DEFAULT 'active' NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY(assignid) REFERENCES assignment (id), + FOREIGN KEY(username) REFERENCES user (name) +); +CREATE TABLE submission_logs ( + sub_id INTEGER NOT NULL, + logs TEXT, + PRIMARY KEY (sub_id), + FOREIGN KEY(sub_id) REFERENCES submission (id) +); +CREATE TABLE submission_properties ( + sub_id INTEGER NOT NULL, + properties TEXT, + PRIMARY KEY (sub_id), + FOREIGN KEY(sub_id) REFERENCES submission (id) +); +CREATE TABLE api_token ( + username VARCHAR(255), + id INTEGER, + hashed VARCHAR(255), + prefix VARCHAR(16), + client_id VARCHAR(255), + session_id VARCHAR(255), + created DATETIME, + expires_at DATETIME, + last_activity DATETIME, + note VARCHAR(1023), + scopes TEXT, + PRIMARY KEY (id), + FOREIGN KEY(username) REFERENCES user (name) +); +INSERT INTO api_token VALUES('instructor',1,'sha512:1:e1256354cdcaa527:2ff86945adeccfe0d7e145f3309ab2bdbe3ebade042040a66fd59c71653d5e0e86137a46a3669c83747b5c30da514a4cc390f9552705c0186b4154f42e078cc8','EFlj','hub','4518493cdf7944589e435d42c739ea7f','2024-11-04 13:12:05.114740','2024-11-18 13:12:05.113774','2024-11-04 13:12:05.126583','','["identify"]'); +CREATE TABLE oauth_client ( + id INTEGER, + identifier VARCHAR(255), + description VARCHAR(1023), + secret VARCHAR(255), + redirect_uri VARCHAR(1023), + allowed_scopes TEXT, + PRIMARY KEY (id), + UNIQUE (identifier) +); +INSERT INTO oauth_client VALUES(1,'hub','hub','sha512:16384:f0aa7f9c0c4a8803:cb18a221118c0358afa76cbb194189d2a01f0df1ab3f280c4dab96d1bb841282da14a47fd215741ec1c1c24cf843a647a7d248adeb1239acf549a23fce68bb24','http://localhost:8080/hub/oauth_callback',NULL); +CREATE TABLE oauth_code ( + id INTEGER, + client_id VARCHAR(255), + code VARCHAR(36), + expires_at INTEGER, + redirect_uri VARCHAR(1023), + session_id VARCHAR(255), + username VARCHAR(255), + scopes TEXT, + PRIMARY KEY (id), + FOREIGN KEY(client_id) REFERENCES oauth_client (identifier), + FOREIGN KEY(username) REFERENCES user (name) +); +CREATE TABLE IF NOT EXISTS "user" ( + name VARCHAR(255) NOT NULL, + encrypted_auth_state BLOB, + cookie_id VARCHAR(255) NOT NULL, + PRIMARY KEY (name), + CONSTRAINT uq_user_cookie UNIQUE (cookie_id) +); +INSERT INTO user VALUES('instructor',NULL,'0eead4756d8a4e9f8af65949b1d8bdd6'); +COMMIT; diff --git a/binder/environment.yml b/binder/environment.yml index 8b232c4..91e2a9c 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -25,31 +25,22 @@ dependencies: - jupyterlab-geojson=3.4 # Python Kernel - ipykernel=6.24.0 - - xeus-python=0.14.3 - ipywidgets=8 - ipyleaflet=0.17.3 - altair=5.0.1 - bqplot=0.12.40 - - dask=2023.7.0 - matplotlib-base=3.7.1 - pandas=2.0.3 - - python=3.10 + - python=3.11 - scikit-image=0.21.0 - scikit-learn=1.3.0 - seaborn-base=0.12.2 - - sympy=1.12 - traittypes=0.2.1 - # C++ Kernel - - xeus-cling=0.13.0 - - xtensor=0.23.10 - - xtensor-blas=0.19.2 - - xwidgets=0.26.1 - - xleaflet=0.16.0 # CLI tools - pip - vim - git + - sqlite - pip: - - grader-service==0.3.0 - - grader-labextension==0.4.0 - - traitlets==5.9.0 \ No newline at end of file + - grader-service==0.5.1 + - grader-labextension==0.6.1 diff --git a/binder/grader_service_config.py b/binder/grader_service_config.py index 33e521c..ece6f8e 100644 --- a/binder/grader_service_config.py +++ b/binder/grader_service_config.py @@ -1,12 +1,30 @@ +print("Loading config file") + import os from grader_service.autograding.local_grader import LocalAutogradeExecutor c.GraderService.service_host = "127.0.0.1" -c.JupyterHubGroupAuthenticator.hub_api_url = "http://127.0.0.1:8081/hub/api" - -c.LocalAutogradeExecutor.relative_input_path = "convert_in" -c.LocalAutogradeExecutor.relative_output_path = "convert_out" +import os +cwd = os.getcwd() +c.GraderService.grader_service_dir = os.path.join(cwd, "grader_service_dir") c.RequestHandlerConfig.autograde_executor_class = LocalAutogradeExecutor + +c.CeleryApp.conf = dict( + broker_url='amqp://localhost', + result_backend='rpc://', + task_serializer='json', + result_serializer='json', + accept_content=['json'], + broker_connection_retry_on_startup=True, + task_always_eager=True +) +c.CeleryApp.worker_kwargs = dict(concurrency=1, pool="prefork") + +from grader_service.auth.dummy import DummyAuthenticator + +c.GraderService.authenticator_class = DummyAuthenticator +c.Authenticator.allowed_users = {'admin', 'instructor', 'student', 'tutor'} +c.Authenticator.admin_users = {'admin'} diff --git a/binder/hub_auth_mock.py b/binder/hub_auth_mock.py deleted file mode 100644 index 76531be..0000000 --- a/binder/hub_auth_mock.py +++ /dev/null @@ -1,26 +0,0 @@ -import asyncio -import tornado.web - - -class MainHandler(tornado.web.RequestHandler): - def get(self): - print("Returning dummy user") - self.set_header("Content-Type", "application/json") - dummy_user = '{"kind": "user", "admin": true, "groups": ["lect1:instructor"], "name": "instructor"}' - self.write(dummy_user) - - -def make_app(): - return tornado.web.Application([ - (r"/hub/api/user", MainHandler), - ]) - - -async def main(): - app = make_app() - app.listen(8081) - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/binder/start b/binder/start index ecbd5b6..06eb614 100644 --- a/binder/start +++ b/binder/start @@ -6,11 +6,13 @@ mkdir "./grader_service_dir" export GRADER_SERVICE_DIRECTORY="$(pwd)/grader_service_dir" export JUPYTERHUB_API_TOKEN="1234" export JUPYTERHUB_API_URL="http://127.0.0.1:4010" - -python3 binder/hub_auth_mock.py & +export GRADER_API_TOKEN="EFljC6HTHSB1EzWzBNNFLaZ5lmLb9k" cd grader_service_dir -grader-service-migrate +# grader-service-migrate + +sqlite3 grader.db < db.sql + cd .. grader-service -f binder/grader_service_config.py & diff --git a/grader_labextension/__init__.py b/grader_labextension/__init__.py index 4ae2e49..56d5fba 100644 --- a/grader_labextension/__init__.py +++ b/grader_labextension/__init__.py @@ -9,11 +9,9 @@ __version__ = "dev" import asyncio -from tornado.httpclient import HTTPClientError from grader_labextension.registry import HandlerPathRegistry from grader_labextension.handlers.base_handler import HandlerConfig -from traitlets.config.loader import Config -from grader_labextension.services.request import RequestService +from grader_labextension.services.request import RequestService, RequestServiceError def _jupyter_labextension_paths(): @@ -62,13 +60,13 @@ async def get_grader_config(): try: response: dict = await request_service.request( "GET", - f"{handler_config.service_base_url}/config", + f"{handler_config.service_base_url}api/config", header=dict( - Authorization="Token " + HandlerConfig.instance().hub_api_token), + Authorization="Token " + HandlerConfig.instance().grader_api_token), ) - except HTTPClientError as e: + except RequestServiceError as e: log.error("Error: could not get grader config") - log.error(e.response) + log.error(e) response = dict() for key, value in response.items(): web_app.settings['page_config_data'][key] = value @@ -81,7 +79,7 @@ async def get_grader_config(): log.info(f'{web_app.settings["server_root_dir"]=}') log.info("base_url: " + base_url) handlers = HandlerPathRegistry.handler_list( - base_url=base_url + "grader_labextension") + base_url=base_url + "grader_labextension/") log.info("Subscribed handlers:") log.info([str(h[0]) for h in handlers]) diff --git a/grader_labextension/api/models/assignment.py b/grader_labextension/api/models/assignment.py index 51b4c1f..6e92aa7 100644 --- a/grader_labextension/api/models/assignment.py +++ b/grader_labextension/api/models/assignment.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api.models.assignment_settings import AssignmentSettings from grader_labextension.api import util @@ -90,7 +87,7 @@ def from_dict(cls, dikt) -> 'Assignment': return util.deserialize_model(dikt, cls) @property - def id(self): + def id(self) -> int: """Gets the id of this Assignment. @@ -100,7 +97,7 @@ def id(self): return self._id @id.setter - def id(self, id): + def id(self, id: int): """Sets the id of this Assignment. @@ -111,7 +108,7 @@ def id(self, id): self._id = id @property - def name(self): + def name(self) -> str: """Gets the name of this Assignment. @@ -121,7 +118,7 @@ def name(self): return self._name @name.setter - def name(self, name): + def name(self, name: str): """Sets the name of this Assignment. @@ -132,7 +129,7 @@ def name(self, name): self._name = name @property - def type(self): + def type(self) -> str: """Gets the type of this Assignment. @@ -142,7 +139,7 @@ def type(self): return self._type @type.setter - def type(self, type): + def type(self, type: str): """Sets the type of this Assignment. @@ -159,7 +156,7 @@ def type(self, type): self._type = type @property - def due_date(self): + def due_date(self) -> datetime: """Gets the due_date of this Assignment. @@ -169,7 +166,7 @@ def due_date(self): return self._due_date @due_date.setter - def due_date(self, due_date): + def due_date(self, due_date: datetime): """Sets the due_date of this Assignment. @@ -180,7 +177,7 @@ def due_date(self, due_date): self._due_date = due_date @property - def status(self): + def status(self) -> str: """Gets the status of this Assignment. @@ -190,7 +187,7 @@ def status(self): return self._status @status.setter - def status(self, status): + def status(self, status: str): """Sets the status of this Assignment. @@ -207,7 +204,7 @@ def status(self, status): self._status = status @property - def points(self): + def points(self) -> float: """Gets the points of this Assignment. @@ -217,7 +214,7 @@ def points(self): return self._points @points.setter - def points(self, points): + def points(self, points: float): """Sets the points of this Assignment. @@ -228,7 +225,7 @@ def points(self, points): self._points = points @property - def automatic_grading(self): + def automatic_grading(self) -> str: """Gets the automatic_grading of this Assignment. @@ -238,7 +235,7 @@ def automatic_grading(self): return self._automatic_grading @automatic_grading.setter - def automatic_grading(self, automatic_grading): + def automatic_grading(self, automatic_grading: str): """Sets the automatic_grading of this Assignment. @@ -255,7 +252,7 @@ def automatic_grading(self, automatic_grading): self._automatic_grading = automatic_grading @property - def max_submissions(self): + def max_submissions(self) -> int: """Gets the max_submissions of this Assignment. @@ -265,7 +262,7 @@ def max_submissions(self): return self._max_submissions @max_submissions.setter - def max_submissions(self, max_submissions): + def max_submissions(self, max_submissions: int): """Sets the max_submissions of this Assignment. @@ -276,7 +273,7 @@ def max_submissions(self, max_submissions): self._max_submissions = max_submissions @property - def allow_files(self): + def allow_files(self) -> bool: """Gets the allow_files of this Assignment. @@ -286,7 +283,7 @@ def allow_files(self): return self._allow_files @allow_files.setter - def allow_files(self, allow_files): + def allow_files(self, allow_files: bool): """Sets the allow_files of this Assignment. @@ -297,7 +294,7 @@ def allow_files(self, allow_files): self._allow_files = allow_files @property - def settings(self): + def settings(self) -> AssignmentSettings: """Gets the settings of this Assignment. @@ -307,7 +304,7 @@ def settings(self): return self._settings @settings.setter - def settings(self, settings): + def settings(self, settings: AssignmentSettings): """Sets the settings of this Assignment. diff --git a/grader_labextension/api/models/assignment_detail.py b/grader_labextension/api/models/assignment_detail.py index f5e5ddb..f9cd6ce 100644 --- a/grader_labextension/api/models/assignment_detail.py +++ b/grader_labextension/api/models/assignment_detail.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api.models.submission import Submission from grader_labextension.api import util @@ -90,7 +87,7 @@ def from_dict(cls, dikt) -> 'AssignmentDetail': return util.deserialize_model(dikt, cls) @property - def id(self): + def id(self) -> int: """Gets the id of this AssignmentDetail. @@ -100,7 +97,7 @@ def id(self): return self._id @id.setter - def id(self, id): + def id(self, id: int): """Sets the id of this AssignmentDetail. @@ -111,7 +108,7 @@ def id(self, id): self._id = id @property - def name(self): + def name(self) -> str: """Gets the name of this AssignmentDetail. @@ -121,7 +118,7 @@ def name(self): return self._name @name.setter - def name(self, name): + def name(self, name: str): """Sets the name of this AssignmentDetail. @@ -132,7 +129,7 @@ def name(self, name): self._name = name @property - def type(self): + def type(self) -> str: """Gets the type of this AssignmentDetail. @@ -142,7 +139,7 @@ def type(self): return self._type @type.setter - def type(self, type): + def type(self, type: str): """Sets the type of this AssignmentDetail. @@ -159,7 +156,7 @@ def type(self, type): self._type = type @property - def due_date(self): + def due_date(self) -> datetime: """Gets the due_date of this AssignmentDetail. @@ -169,7 +166,7 @@ def due_date(self): return self._due_date @due_date.setter - def due_date(self, due_date): + def due_date(self, due_date: datetime): """Sets the due_date of this AssignmentDetail. @@ -180,7 +177,7 @@ def due_date(self, due_date): self._due_date = due_date @property - def status(self): + def status(self) -> str: """Gets the status of this AssignmentDetail. @@ -190,7 +187,7 @@ def status(self): return self._status @status.setter - def status(self, status): + def status(self, status: str): """Sets the status of this AssignmentDetail. @@ -207,7 +204,7 @@ def status(self, status): self._status = status @property - def points(self): + def points(self) -> float: """Gets the points of this AssignmentDetail. @@ -217,7 +214,7 @@ def points(self): return self._points @points.setter - def points(self, points): + def points(self, points: float): """Sets the points of this AssignmentDetail. @@ -228,7 +225,7 @@ def points(self, points): self._points = points @property - def automatic_grading(self): + def automatic_grading(self) -> str: """Gets the automatic_grading of this AssignmentDetail. @@ -238,7 +235,7 @@ def automatic_grading(self): return self._automatic_grading @automatic_grading.setter - def automatic_grading(self, automatic_grading): + def automatic_grading(self, automatic_grading: str): """Sets the automatic_grading of this AssignmentDetail. @@ -255,7 +252,7 @@ def automatic_grading(self, automatic_grading): self._automatic_grading = automatic_grading @property - def max_submissions(self): + def max_submissions(self) -> int: """Gets the max_submissions of this AssignmentDetail. @@ -265,7 +262,7 @@ def max_submissions(self): return self._max_submissions @max_submissions.setter - def max_submissions(self, max_submissions): + def max_submissions(self, max_submissions: int): """Sets the max_submissions of this AssignmentDetail. @@ -276,7 +273,7 @@ def max_submissions(self, max_submissions): self._max_submissions = max_submissions @property - def allow_files(self): + def allow_files(self) -> bool: """Gets the allow_files of this AssignmentDetail. @@ -286,7 +283,7 @@ def allow_files(self): return self._allow_files @allow_files.setter - def allow_files(self, allow_files): + def allow_files(self, allow_files: bool): """Sets the allow_files of this AssignmentDetail. @@ -297,7 +294,7 @@ def allow_files(self, allow_files): self._allow_files = allow_files @property - def submissions(self): + def submissions(self) -> List[Submission]: """Gets the submissions of this AssignmentDetail. @@ -307,7 +304,7 @@ def submissions(self): return self._submissions @submissions.setter - def submissions(self, submissions): + def submissions(self, submissions: List[Submission]): """Sets the submissions of this AssignmentDetail. diff --git a/grader_labextension/api/models/assignment_settings.py b/grader_labextension/api/models/assignment_settings.py index e55b6dc..8712513 100644 --- a/grader_labextension/api/models/assignment_settings.py +++ b/grader_labextension/api/models/assignment_settings.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api.models.submission_period import SubmissionPeriod from grader_labextension.api import util @@ -45,7 +42,7 @@ def from_dict(cls, dikt) -> 'AssignmentSettings': return util.deserialize_model(dikt, cls) @property - def late_submission(self): + def late_submission(self) -> List[SubmissionPeriod]: """Gets the late_submission of this AssignmentSettings. @@ -55,7 +52,7 @@ def late_submission(self): return self._late_submission @late_submission.setter - def late_submission(self, late_submission): + def late_submission(self, late_submission: List[SubmissionPeriod]): """Sets the late_submission of this AssignmentSettings. diff --git a/grader_labextension/api/models/base_model_.py b/grader_labextension/api/models/base_model.py similarity index 95% rename from grader_labextension/api/models/base_model_.py rename to grader_labextension/api/models/base_model.py index c9ecec7..48bcaf9 100644 --- a/grader_labextension/api/models/base_model_.py +++ b/grader_labextension/api/models/base_model.py @@ -1,6 +1,5 @@ import pprint -import six import typing from grader_labextension.api import util @@ -8,7 +7,7 @@ T = typing.TypeVar('T') -class Model(object): +class Model: # openapiTypes: The key is attribute name and the # value is attribute type. openapi_types: typing.Dict[str, type] = {} @@ -29,7 +28,7 @@ def to_dict(self): """ result = {} - for attr, _ in six.iteritems(self.openapi_types): + for attr in self.openapi_types: value = getattr(self, attr) if isinstance(value, list): result[attr] = list(map( diff --git a/grader_labextension/api/models/error_message.py b/grader_labextension/api/models/error_message.py index a994268..da56073 100644 --- a/grader_labextension/api/models/error_message.py +++ b/grader_labextension/api/models/error_message.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api import util @@ -63,7 +60,7 @@ def from_dict(cls, dikt) -> 'ErrorMessage': return util.deserialize_model(dikt, cls) @property - def code(self): + def code(self) -> int: """Gets the code of this ErrorMessage. @@ -73,7 +70,7 @@ def code(self): return self._code @code.setter - def code(self, code): + def code(self, code: int): """Sets the code of this ErrorMessage. @@ -86,7 +83,7 @@ def code(self, code): self._code = code @property - def error(self): + def error(self) -> str: """Gets the error of this ErrorMessage. @@ -96,7 +93,7 @@ def error(self): return self._error @error.setter - def error(self, error): + def error(self, error: str): """Sets the error of this ErrorMessage. @@ -109,7 +106,7 @@ def error(self, error): self._error = error @property - def path(self): + def path(self) -> str: """Gets the path of this ErrorMessage. @@ -119,7 +116,7 @@ def path(self): return self._path @path.setter - def path(self, path): + def path(self, path: str): """Sets the path of this ErrorMessage. @@ -132,7 +129,7 @@ def path(self, path): self._path = path @property - def message(self): + def message(self) -> str: """Gets the message of this ErrorMessage. @@ -142,7 +139,7 @@ def message(self): return self._message @message.setter - def message(self, message): + def message(self, message: str): """Sets the message of this ErrorMessage. @@ -153,7 +150,7 @@ def message(self, message): self._message = message @property - def traceback(self): + def traceback(self) -> str: """Gets the traceback of this ErrorMessage. @@ -163,7 +160,7 @@ def traceback(self): return self._traceback @traceback.setter - def traceback(self, traceback): + def traceback(self, traceback: str): """Sets the traceback of this ErrorMessage. diff --git a/grader_labextension/api/models/lecture.py b/grader_labextension/api/models/lecture.py index 900cfa2..698444c 100644 --- a/grader_labextension/api/models/lecture.py +++ b/grader_labextension/api/models/lecture.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api import util @@ -58,7 +55,7 @@ def from_dict(cls, dikt) -> 'Lecture': return util.deserialize_model(dikt, cls) @property - def id(self): + def id(self) -> int: """Gets the id of this Lecture. @@ -68,7 +65,7 @@ def id(self): return self._id @id.setter - def id(self, id): + def id(self, id: int): """Sets the id of this Lecture. @@ -79,7 +76,7 @@ def id(self, id): self._id = id @property - def name(self): + def name(self) -> str: """Gets the name of this Lecture. @@ -89,7 +86,7 @@ def name(self): return self._name @name.setter - def name(self, name): + def name(self, name: str): """Sets the name of this Lecture. @@ -100,7 +97,7 @@ def name(self, name): self._name = name @property - def code(self): + def code(self) -> str: """Gets the code of this Lecture. @@ -110,7 +107,7 @@ def code(self): return self._code @code.setter - def code(self, code): + def code(self, code: str): """Sets the code of this Lecture. @@ -121,7 +118,7 @@ def code(self, code): self._code = code @property - def complete(self): + def complete(self) -> bool: """Gets the complete of this Lecture. @@ -131,7 +128,7 @@ def complete(self): return self._complete @complete.setter - def complete(self, complete): + def complete(self, complete: bool): """Sets the complete of this Lecture. diff --git a/grader_labextension/api/models/remote_file_status.py b/grader_labextension/api/models/remote_file_status.py new file mode 100644 index 0000000..df288c0 --- /dev/null +++ b/grader_labextension/api/models/remote_file_status.py @@ -0,0 +1,67 @@ +from datetime import date, datetime # noqa: F401 + +from typing import List, Dict # noqa: F401 + +from grader_labextension.api.models.base_model import Model +from grader_labextension.api import util + + +class RemoteFileStatus(Model): + """NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + + Do not edit the class manually. + """ + + def __init__(self, status=None): # noqa: E501 + """RemoteFileStatus - a model defined in OpenAPI + + :param status: The status of this RemoteFileStatus. # noqa: E501 + :type status: str + """ + self.openapi_types = { + 'status': str + } + + self.attribute_map = { + 'status': 'status' + } + + self._status = status + + @classmethod + def from_dict(cls, dikt) -> 'RemoteFileStatus': + """Returns the dict as a model + + :param dikt: A dict. + :type: dict + :return: The RemoteFileStatus of this RemoteFileStatus. # noqa: E501 + :rtype: RemoteFileStatus + """ + return util.deserialize_model(dikt, cls) + + @property + def status(self) -> str: + """Gets the status of this RemoteFileStatus. + + + :return: The status of this RemoteFileStatus. + :rtype: str + """ + return self._status + + @status.setter + def status(self, status: str): + """Sets the status of this RemoteFileStatus. + + + :param status: The status of this RemoteFileStatus. + :type status: str + """ + allowed_values = ["UP_TO_DATE", "DIVERGENT", "PULL_NEEDED", "PUSH_NEEDED", "NO_REMOTE_REPO"] # noqa: E501 + if status not in allowed_values: + raise ValueError( + "Invalid value for `status` ({0}), must be one of {1}" + .format(status, allowed_values) + ) + + self._status = status diff --git a/grader_labextension/api/models/submission.py b/grader_labextension/api/models/submission.py index 30ed916..be56e0e 100644 --- a/grader_labextension/api/models/submission.py +++ b/grader_labextension/api/models/submission.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api import util @@ -98,7 +95,7 @@ def from_dict(cls, dikt) -> 'Submission': return util.deserialize_model(dikt, cls) @property - def id(self): + def id(self) -> int: """Gets the id of this Submission. @@ -108,7 +105,7 @@ def id(self): return self._id @id.setter - def id(self, id): + def id(self, id: int): """Sets the id of this Submission. @@ -119,7 +116,7 @@ def id(self, id): self._id = id @property - def submitted_at(self): + def submitted_at(self) -> datetime: """Gets the submitted_at of this Submission. @@ -129,7 +126,7 @@ def submitted_at(self): return self._submitted_at @submitted_at.setter - def submitted_at(self, submitted_at): + def submitted_at(self, submitted_at: datetime): """Sets the submitted_at of this Submission. @@ -140,7 +137,7 @@ def submitted_at(self, submitted_at): self._submitted_at = submitted_at @property - def auto_status(self): + def auto_status(self) -> str: """Gets the auto_status of this Submission. @@ -150,7 +147,7 @@ def auto_status(self): return self._auto_status @auto_status.setter - def auto_status(self, auto_status): + def auto_status(self, auto_status: str): """Sets the auto_status of this Submission. @@ -167,7 +164,7 @@ def auto_status(self, auto_status): self._auto_status = auto_status @property - def manual_status(self): + def manual_status(self) -> str: """Gets the manual_status of this Submission. @@ -177,7 +174,7 @@ def manual_status(self): return self._manual_status @manual_status.setter - def manual_status(self, manual_status): + def manual_status(self, manual_status: str): """Sets the manual_status of this Submission. @@ -194,7 +191,7 @@ def manual_status(self, manual_status): self._manual_status = manual_status @property - def username(self): + def username(self) -> str: """Gets the username of this Submission. @@ -204,7 +201,7 @@ def username(self): return self._username @username.setter - def username(self, username): + def username(self, username: str): """Sets the username of this Submission. @@ -215,7 +212,7 @@ def username(self, username): self._username = username @property - def grading_score(self): + def grading_score(self) -> float: """Gets the grading_score of this Submission. @@ -225,7 +222,7 @@ def grading_score(self): return self._grading_score @grading_score.setter - def grading_score(self, grading_score): + def grading_score(self, grading_score: float): """Sets the grading_score of this Submission. @@ -236,7 +233,7 @@ def grading_score(self, grading_score): self._grading_score = grading_score @property - def score_scaling(self): + def score_scaling(self) -> float: """Gets the score_scaling of this Submission. @@ -246,7 +243,7 @@ def score_scaling(self): return self._score_scaling @score_scaling.setter - def score_scaling(self, score_scaling): + def score_scaling(self, score_scaling: float): """Sets the score_scaling of this Submission. @@ -261,7 +258,7 @@ def score_scaling(self, score_scaling): self._score_scaling = score_scaling @property - def score(self): + def score(self) -> float: """Gets the score of this Submission. @@ -271,7 +268,7 @@ def score(self): return self._score @score.setter - def score(self, score): + def score(self, score: float): """Sets the score of this Submission. @@ -282,7 +279,7 @@ def score(self, score): self._score = score @property - def assignid(self): + def assignid(self) -> int: """Gets the assignid of this Submission. @@ -292,7 +289,7 @@ def assignid(self): return self._assignid @assignid.setter - def assignid(self, assignid): + def assignid(self, assignid: int): """Sets the assignid of this Submission. @@ -303,7 +300,7 @@ def assignid(self, assignid): self._assignid = assignid @property - def commit_hash(self): + def commit_hash(self) -> str: """Gets the commit_hash of this Submission. @@ -313,7 +310,7 @@ def commit_hash(self): return self._commit_hash @commit_hash.setter - def commit_hash(self, commit_hash): + def commit_hash(self, commit_hash: str): """Sets the commit_hash of this Submission. @@ -324,7 +321,7 @@ def commit_hash(self, commit_hash): self._commit_hash = commit_hash @property - def feedback_status(self): + def feedback_status(self) -> str: """Gets the feedback_status of this Submission. @@ -334,7 +331,7 @@ def feedback_status(self): return self._feedback_status @feedback_status.setter - def feedback_status(self, feedback_status): + def feedback_status(self, feedback_status: str): """Sets the feedback_status of this Submission. @@ -344,14 +341,14 @@ def feedback_status(self, feedback_status): allowed_values = ["not_generated", "generating", "generated", "generation_failed", "feedback_outdated"] # noqa: E501 if feedback_status not in allowed_values: raise ValueError( - "Invalid value for `auto_status` ({0}), must be one of {1}" + "Invalid value for `feedback_status` ({0}), must be one of {1}" .format(feedback_status, allowed_values) ) self._feedback_status = feedback_status @property - def edited(self): + def edited(self) -> bool: """Gets the edited of this Submission. @@ -361,7 +358,7 @@ def edited(self): return self._edited @edited.setter - def edited(self, edited): + def edited(self, edited: bool): """Sets the edited of this Submission. diff --git a/grader_labextension/api/models/submission_period.py b/grader_labextension/api/models/submission_period.py index 08173bb..87972c7 100644 --- a/grader_labextension/api/models/submission_period.py +++ b/grader_labextension/api/models/submission_period.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api import util @@ -48,7 +45,7 @@ def from_dict(cls, dikt) -> 'SubmissionPeriod': return util.deserialize_model(dikt, cls) @property - def period(self): + def period(self) -> str: """Gets the period of this SubmissionPeriod. @@ -58,7 +55,7 @@ def period(self): return self._period @period.setter - def period(self, period): + def period(self, period: str): """Sets the period of this SubmissionPeriod. @@ -69,7 +66,7 @@ def period(self, period): self._period = period @property - def scaling(self): + def scaling(self) -> float: """Gets the scaling of this SubmissionPeriod. @@ -79,7 +76,7 @@ def scaling(self): return self._scaling @scaling.setter - def scaling(self, scaling): + def scaling(self, scaling: float): """Sets the scaling of this SubmissionPeriod. diff --git a/grader_labextension/api/models/user.py b/grader_labextension/api/models/user.py index c53a5dc..0165360 100644 --- a/grader_labextension/api/models/user.py +++ b/grader_labextension/api/models/user.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api import util @@ -43,7 +40,7 @@ def from_dict(cls, dikt) -> 'User': return util.deserialize_model(dikt, cls) @property - def name(self): + def name(self) -> str: """Gets the name of this User. @@ -53,7 +50,7 @@ def name(self): return self._name @name.setter - def name(self, name): + def name(self, name: str): """Sets the name of this User. diff --git a/grader_labextension/api/models/user_submissions_inner.py b/grader_labextension/api/models/user_submissions_inner.py index e4fc03d..f07e7f8 100644 --- a/grader_labextension/api/models/user_submissions_inner.py +++ b/grader_labextension/api/models/user_submissions_inner.py @@ -1,11 +1,8 @@ -# coding: utf-8 - -from __future__ import absolute_import from datetime import date, datetime # noqa: F401 from typing import List, Dict # noqa: F401 -from grader_labextension.api.models.base_model_ import Model +from grader_labextension.api.models.base_model import Model from grader_labextension.api.models.submission import Submission from grader_labextension.api.models.user import User from grader_labextension.api import util @@ -52,7 +49,7 @@ def from_dict(cls, dikt) -> 'UserSubmissionsInner': return util.deserialize_model(dikt, cls) @property - def user(self): + def user(self) -> User: """Gets the user of this UserSubmissionsInner. @@ -62,7 +59,7 @@ def user(self): return self._user @user.setter - def user(self, user): + def user(self, user: User): """Sets the user of this UserSubmissionsInner. @@ -73,7 +70,7 @@ def user(self, user): self._user = user @property - def submissions(self): + def submissions(self) -> List[Submission]: """Gets the submissions of this UserSubmissionsInner. @@ -83,7 +80,7 @@ def submissions(self): return self._submissions @submissions.setter - def submissions(self, submissions): + def submissions(self, submissions: List[Submission]): """Sets the submissions of this UserSubmissionsInner. diff --git a/grader_labextension/api/typing_utils.py b/grader_labextension/api/typing_utils.py index 0563f81..74e3c91 100644 --- a/grader_labextension/api/typing_utils.py +++ b/grader_labextension/api/typing_utils.py @@ -1,5 +1,3 @@ -# coding: utf-8 - import sys if sys.version_info < (3, 7): diff --git a/grader_labextension/api/util.py b/grader_labextension/api/util.py index a4122c1..b49ffb4 100644 --- a/grader_labextension/api/util.py +++ b/grader_labextension/api/util.py @@ -1,6 +1,5 @@ import datetime -import six import typing from grader_labextension.api import typing_utils @@ -16,7 +15,7 @@ def _deserialize(data, klass): if data is None: return None - if klass in six.integer_types or klass in (float, str, bool, bytearray): + if klass in (int, float, str, bool, bytearray): return _deserialize_primitive(data, klass) elif klass == object: return _deserialize_object(data) @@ -45,7 +44,7 @@ def _deserialize_primitive(data, klass): try: value = klass(data) except UnicodeEncodeError: - value = six.u(data) + value = data except TypeError: value = data return value @@ -110,7 +109,7 @@ def deserialize_model(data, klass): if not instance.openapi_types: return data - for attr, attr_type in six.iteritems(instance.openapi_types): + for attr, attr_type in instance.openapi_types.items(): if data is not None \ and instance.attribute_map[attr] in data \ and isinstance(data, (list, dict)): @@ -145,4 +144,4 @@ def _deserialize_dict(data, boxed_type): :rtype: dict """ return {k: _deserialize(v, boxed_type) - for k, v in six.iteritems(data)} + for k, v in data.items() } diff --git a/grader_labextension/api_handler.py b/grader_labextension/api_handler.py index 4976a5c..c1f471b 100644 --- a/grader_labextension/api_handler.py +++ b/grader_labextension/api_handler.py @@ -13,6 +13,6 @@ class HealthHandler(ExtensionBaseHandler): @tornado.web.authenticated def get(self): - response = "Grader Labextension: Health OK" + response = {"health": "OK"} self.write(response) diff --git a/grader_labextension/handlers/assignment.py b/grader_labextension/handlers/assignment.py index 56a14c3..aa133bf 100644 --- a/grader_labextension/handlers/assignment.py +++ b/grader_labextension/handlers/assignment.py @@ -7,23 +7,22 @@ import json import shutil -from tornado.httpclient import HTTPClientError -from tornado.web import HTTPError +from tornado.web import HTTPError, authenticated from grader_labextension.registry import register_handler from grader_labextension.handlers.base_handler import ExtensionBaseHandler, cache -from grader_labextension.services.request import RequestService +from grader_labextension.services.request import RequestService, RequestServiceError import tornado import os -@register_handler(path=r"\/lectures\/(?P\d*)\/assignments\/?") +@register_handler(path=r"api\/lectures\/(?P\d*)\/assignments\/?") class AssignmentBaseHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments. """ - @cache(max_age=30) + @authenticated async def get(self, lecture_id: int): """Sends a GET request to the grader service and returns assignments of the lecture @@ -33,19 +32,19 @@ async def get(self, lecture_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments", header=self.grader_authentication_header, response_callback=self.set_service_headers ) lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) # Create directories for every assignment try: @@ -77,19 +76,19 @@ async def post(self, lecture_id: int): try: response = await self.request_service.request( method="POST", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments", body=data, header=self.grader_authentication_header, ) lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) # if we did not get an error when creating the assignment (i.e. the user is authorized etc.) then we can # create the directory structure if it does not exist yet os.makedirs( @@ -108,7 +107,7 @@ async def post(self, lecture_id: int): @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/?" ) class AssignmentObjectHandler(ExtensionBaseHandler): """ @@ -128,16 +127,16 @@ async def put(self, lecture_id: int, assignment_id: int): try: response = await self.request_service.request( method="PUT", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", body=data, header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) - @cache(max_age=30) + @authenticated async def get(self, lecture_id: int, assignment_id: int): """Sends a GET-request to the grader service to get a specific assignment @@ -147,27 +146,21 @@ async def get(self, lecture_id: int, assignment_id: int): :type assignment_id: int """ - query_params = RequestService.get_query_string( - { - "instructor-version": self.get_argument("instructor-version", None), - } - ) - try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}{query_params}", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, response_callback=self.set_service_headers ) lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) os.makedirs( os.path.expanduser(f'{self.root_dir}/{lecture["code"]}/assignments/{response["id"]}'), @@ -187,24 +180,25 @@ async def delete(self, lecture_id: int, assignment_id: int): try: await self.request_service.request( method="DELETE", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, decode_response=False ) - except HTTPClientError as e: - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + raise HTTPError(e.code, reason=e.message) - self.write("OK") + self.write({"status": "OK"}) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/properties\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/properties\/?" ) class AssignmentPropertiesHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/properties. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int): """Sends a GET-request to the grader service and returns the properties of an assignment @@ -217,12 +211,12 @@ async def get(self, lecture_id: int, assignment_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/properties", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/properties", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) diff --git a/grader_labextension/handlers/base_handler.py b/grader_labextension/handlers/base_handler.py index a8535f7..befd602 100644 --- a/grader_labextension/handlers/base_handler.py +++ b/grader_labextension/handlers/base_handler.py @@ -13,10 +13,10 @@ from tornado.web import HTTPError from grader_labextension.api.models.error_message import ErrorMessage -from grader_labextension.services.request import RequestService +from grader_labextension.services.request import RequestService, RequestServiceError from jupyter_server.base.handlers import APIHandler import os -from tornado.httpclient import HTTPClientError, HTTPResponse +from tornado.httpclient import HTTPResponse from traitlets.config.configurable import SingletonConfigurable from traitlets.traitlets import Unicode @@ -41,8 +41,10 @@ class HandlerConfig(SingletonConfigurable): hub_api_token = Unicode(os.environ.get("JUPYTERHUB_API_TOKEN"), help="The authorization token to access the hub api").tag(config=True) hub_user = Unicode(os.environ.get("JUPYTERHUB_USER"), help="The user name in jupyter hub.").tag(config=True) + grader_api_token = Unicode(os.environ.get("GRADER_API_TOKEN"), + help="The authorization token to access the grader service api").tag(config=True) service_base_url = Unicode( - os.environ.get("GRADER_BASE_URL", "/services/grader"), + os.environ.get("GRADER_BASE_URL", "/services/grader/"), help="Base URL to use for each request to the grader service", ).tag(config=True) lectures_base_path = Unicode( @@ -83,7 +85,7 @@ def grader_authentication_header(self): :rtype: dict """ - return dict(Authorization="Token " + HandlerConfig.instance().hub_api_token) + return dict(Authorization="Token " + HandlerConfig.instance().grader_api_token) @property def user_name(self): @@ -93,42 +95,23 @@ async def get_lecture(self, lecture_id) -> dict: try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) return lecture - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) async def get_assignment(self, lecture_id, assignment_id): try: assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) return assignment - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) - - def write_error(self, status_code, **kwargs): - """APIHandler errors are JSON, not human pages""" - self.set_header("Content-Type", "application/json") - message = responses.get(status_code, "Unknown HTTP Error") - reply: dict = { - "message": message, - } - exc_info = kwargs.get("exc_info") - if exc_info: - e = exc_info[1] - if isinstance(e, HTTPError): - reply["message"] = e.log_message or message - reply["reason"] = e.reason - else: - reply["message"] = "Unhandled error" - reply["reason"] = None - reply["traceback"] = "".join(traceback.format_exception(*exc_info)) - self.log.warning("wrote error: %r", reply["message"], exc_info=True) - self.finish(json.dumps(reply)) \ No newline at end of file + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + diff --git a/grader_labextension/handlers/grading.py b/grader_labextension/handlers/grading.py index 9ec964d..7734f80 100644 --- a/grader_labextension/handlers/grading.py +++ b/grader_labextension/handlers/grading.py @@ -5,17 +5,17 @@ # LICENSE file in the root directory of this source tree. import shutil -from grader_labextension.services.request import RequestService +from grader_labextension.services.request import RequestService, RequestServiceError from grader_labextension.registry import register_handler from grader_labextension.handlers.base_handler import ExtensionBaseHandler -from tornado.httpclient import HTTPResponse, HTTPClientError -from tornado.web import HTTPError +from tornado.httpclient import HTTPResponse +from tornado.web import HTTPError, authenticated from grader_labextension.services.git import GitService import os @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/save?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/save?" ) class ExportGradesHandler(ExtensionBaseHandler): """ @@ -39,13 +39,13 @@ async def put(self, lecture_id: int, assignment_id: int): try: response: HTTPResponse = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions{query_params}", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions{query_params}", header=self.grader_authentication_header, decode_response=False ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) lecture = await self.get_lecture(lecture_id) dir_path = os.path.join(self.root_dir, lecture["code"]) @@ -55,19 +55,20 @@ async def put(self, lecture_id: int, assignment_id: int): with open(file_path, "w") as f: f.write(csv_content) - self.write("OK") + self.write({"status": "OK"}) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/auto\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/auto\/?" ) class GradingAutoHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/auto. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int, sub_id: int): """Sends a GET-request to the grader service to autograde a submission @@ -81,23 +82,24 @@ async def get(self, lecture_id: int, assignment_id: int, sub_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/grading/{sub_id}/auto", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/grading/{sub_id}/auto", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(response) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/manual\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/manual\/?" ) class GradingManualHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/manual. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int, sub_id: int): """Generates a local git repository and pulls autograded files of a submission in the user directory @@ -112,24 +114,24 @@ async def get(self, lecture_id: int, assignment_id: int, sub_id: int): try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) submission = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{sub_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{sub_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) git_service = GitService( server_root_dir=self.root_dir, @@ -159,13 +161,14 @@ async def get(self, lecture_id: int, assignment_id: int, sub_id: int): @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/feedback\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/feedback\/?" ) class GenerateFeedbackHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/feedback. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int, sub_id: int): """Sends a GET-request to the grader service to generate feedback for a graded submission @@ -180,23 +183,24 @@ async def get(self, lecture_id: int, assignment_id: int, sub_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/grading/{sub_id}/feedback", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/grading/{sub_id}/feedback", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(response) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/pull\/feedback\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/grading\/(?P\d*)\/pull\/feedback\/?" ) class PullFeedbackHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/pull/feedback. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int, sub_id: int): """Generates a local git repository and pulls the feedback files of a submission in the user directory @@ -210,24 +214,24 @@ async def get(self, lecture_id: int, assignment_id: int, sub_id: int): try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) submission = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{sub_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{sub_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) git_service = GitService( server_root_dir=self.root_dir, @@ -251,4 +255,4 @@ async def get(self, lecture_id: int, assignment_id: int, sub_id: int): git_service.init() git_service.set_remote("feedback", sub_id=sub_id) git_service.pull("feedback", branch=f"feedback_{submission['commit_hash']}", force=False) - self.write("Pulled Feedback") + self.write({"status": "Pulled Feedback"}) diff --git a/grader_labextension/handlers/lectures.py b/grader_labextension/handlers/lectures.py index 3f370db..3580cca 100644 --- a/grader_labextension/handlers/lectures.py +++ b/grader_labextension/handlers/lectures.py @@ -8,18 +8,16 @@ from grader_labextension.registry import register_handler from grader_labextension.handlers.base_handler import ExtensionBaseHandler, cache import tornado -from tornado import web -from grader_labextension.services.request import RequestService -from tornado.httpclient import HTTPClientError +from tornado.web import authenticated, HTTPError +from grader_labextension.services.request import RequestService, RequestServiceError -@register_handler(path=r"\/lectures\/?") +@register_handler(path=r"api\/lectures\/?") class LectureBaseHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures. """ - @web.authenticated - @cache(max_age=60) + @authenticated async def get(self): """Sends a GET-request to the grader service and returns the autorized lectures """ @@ -29,16 +27,16 @@ async def get(self): try: response = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures{query_params}", + f"{self.service_base_url}api/lectures{query_params}", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise web.HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) - @web.authenticated + @authenticated async def post(self): """Sends a POST-request to the grader service to create a lecture """ @@ -46,22 +44,22 @@ async def post(self): try: response = await self.request_service.request( "POST", - f"{self.service_base_url}/lectures", + f"{self.service_base_url}api/lectures", body=data, header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise web.HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) -@register_handler(path=r"\/lectures\/(?P\d*)\/?") +@register_handler(path=r"api\/lectures\/(?P\d*)\/?") class LectureObjectHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}. """ - @web.authenticated + @authenticated async def put(self, lecture_id: int): """Sends a PUT-request to the grader service to update a lecture @@ -73,17 +71,16 @@ async def put(self, lecture_id: int): try: response_data: dict = await self.request_service.request( "PUT", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", body=data, header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise web.HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response_data)) - @web.authenticated - @cache(max_age=60) + @authenticated async def get(self, lecture_id: int): """Sends a GET-request to the grader service and returns the lecture @@ -93,16 +90,16 @@ async def get(self, lecture_id: int): try: response_data: dict = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise web.HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response_data)) - @web.authenticated + @authenticated async def delete(self, lecture_id: int): """Sends a DELETE-request to the grader service to delete a lecture @@ -113,23 +110,23 @@ async def delete(self, lecture_id: int): try: await self.request_service.request( "DELETE", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise web.HTTPError(e.code, reason=e.response.reason) - self.write("OK") + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + self.write({"status": "OK"}) @register_handler( - path=r"\/lectures\/(?P\d*)\/users\/?" + path=r"api\/lectures\/(?P\d*)\/users\/?" ) class LectureStudentsHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/users. """ - @cache(max_age=60) + @authenticated async def get(self, lecture_id: int): """ Sends a GET request to the grader service and returns attendants of lecture @@ -139,12 +136,12 @@ async def get(self, lecture_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/users/", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/users", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise web.HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) diff --git a/grader_labextension/handlers/permission.py b/grader_labextension/handlers/permission.py index ae74b7a..6021c96 100644 --- a/grader_labextension/handlers/permission.py +++ b/grader_labextension/handlers/permission.py @@ -6,29 +6,31 @@ import json from grader_labextension.registry import register_handler -from grader_labextension.handlers.base_handler import ExtensionBaseHandler, cache -from tornado import web -from tornado.httpclient import HTTPClientError +from grader_labextension.handlers.base_handler import ExtensionBaseHandler +from tornado.web import HTTPError, authenticated +from grader_labextension.services.request import RequestServiceError -@register_handler(path=r"\/permissions\/?") +@register_handler(path=r"api\/permissions\/?") class PermissionBaseHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /permissions. """ - @web.authenticated - @cache(max_age=10) + @authenticated async def get(self): """ Sends a GET-request to the grader service and returns the permissions of a user """ try: - response = await self.request_service.request( + response = await self.request_service.request_with_retries( "GET", - f"{self.service_base_url}/permissions", + f"{self.service_base_url}api/permissions", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise web.HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + except Exception as e: + self.log.error(f'Unexpected Error: {e}') + raise HTTPError(e) self.write(json.dumps(response)) diff --git a/grader_labextension/handlers/submissions.py b/grader_labextension/handlers/submissions.py index 6876a03..bb00a81 100644 --- a/grader_labextension/handlers/submissions.py +++ b/grader_labextension/handlers/submissions.py @@ -5,28 +5,21 @@ # LICENSE file in the root directory of this source tree. import json -import datetime -from http import HTTPStatus - -import requests -from tornado.httpclient import HTTPClientError -from urllib.parse import urlparse, urlunparse - from grader_labextension.registry import register_handler -from grader_labextension.handlers.base_handler import ExtensionBaseHandler, cache, HandlerConfig -from grader_labextension.services.request import RequestService -from tornado.web import HTTPError +from grader_labextension.handlers.base_handler import ExtensionBaseHandler +from grader_labextension.services.request import RequestService, RequestServiceError +from tornado.web import HTTPError, authenticated @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/?" ) class SubmissionHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions. """ - @cache(max_age=15) + @authenticated async def get(self, lecture_id: int, assignment_id: int): """ Sends a GET-request to the grader service and returns submissions of a assignment @@ -44,21 +37,21 @@ async def get(self, lecture_id: int, assignment_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions{query_params}", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions{query_params}", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(" r"?P\d*)\/logs\/?") class SubmissionLogsHandler(ExtensionBaseHandler): - @cache(max_age=15) + @authenticated async def get(self, lecture_id: int, assignment_id: int, submission_id: int): """Sends a GET-request to the grader service and returns the logs of a submission @@ -73,25 +66,26 @@ async def get(self, lecture_id: int, assignment_id: int, submission_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/logs", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/logs", header=self.grader_authentication_header, response_callback=self.set_service_headers ) self.log.info(response) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(response) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(?P\d*)\/properties\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(?P\d*)\/properties\/?" ) class SubmissionPropertiesHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions/properties. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int, submission_id: int): """Sends a GET-request to the grader service and returns the properties of a submission @@ -106,13 +100,13 @@ async def get(self, lecture_id: int, assignment_id: int, submission_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/properties", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/properties", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) async def put(self, lecture_id: int, assignment_id: int, submission_id: int): @@ -128,20 +122,20 @@ async def put(self, lecture_id: int, assignment_id: int, submission_id: int): try: await self.request_service.request( method="PUT", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/properties", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/properties", header=self.grader_authentication_header, body=self.request.body.decode("utf-8"), decode_response=False, request_timeout=300.0 ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) - self.write("OK") + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + self.write({"status": "OK"}) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(?P\d*)\/edit\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(?P\d*)\/edit\/?" ) class SubmissionEditHandler(ExtensionBaseHandler): async def put(self, lecture_id: int, assignment_id: int, submission_id: int): @@ -157,27 +151,27 @@ async def put(self, lecture_id: int, assignment_id: int, submission_id: int): try: response = await self.request_service.request( method="PUT", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/edit", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}/edit", header=self.grader_authentication_header, body=self.request.body.decode("utf-8"), request_timeout=300.0, connect_timeout=300.0 ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(?P\d*)\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/(?P\d*)\/?" ) class SubmissionObjectHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}. """ - @cache(max_age=15) + @authenticated async def get(self, lecture_id: int, assignment_id: int, submission_id: int): """Sends a GET-request to the grader service and returns a submission @@ -192,13 +186,13 @@ async def get(self, lecture_id: int, assignment_id: int, submission_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}", header=self.grader_authentication_header, response_callback=self.set_service_headers ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) async def put(self, lecture_id: int, assignment_id: int, submission_id: int): @@ -214,19 +208,41 @@ async def put(self, lecture_id: int, assignment_id: int, submission_id: int): try: await self.request_service.request( method="PUT", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}", header=self.grader_authentication_header, body=self.request.body.decode("utf-8"), decode_response=False ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) - self.write("OK") + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + self.write({"status": "OK"}) + + async def delete(self, lecture_id: int, assignment_id: int, submission_id: int): + """Sends a DELETE-request to the grader service to "soft"-delete a assignment + + :param lecture_id: id of the lecture + :type lecture_id: int + :param assignment_id: id of the assignment + :type assignment_id: int + :param submission_id: id of the submission + :type submission_id: int + """ + try: + await self.request_service.request( + method="DELETE", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission_id}", + header=self.grader_authentication_header, + decode_response=False + ) + except RequestServiceError as e: + raise HTTPError(e.code, reason=e.message) + + self.write({"status": "OK"}) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/lti\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/lti\/?" ) class LtiSyncHandler(ExtensionBaseHandler): @@ -244,10 +260,41 @@ async def put(self, lecture_id: int, assignment_id: int): try: response = await self.request_service.request( method="GET", - endpoint=f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/lti", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/lti", header=self.grader_authentication_header) - except HTTPClientError as e: - self.log.error(e.response.body) - raise HTTPError(e.code, reason=json.loads(e.response.body).get("message", "Error while syncing grades")) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) self.write(json.dumps(response)) + +@register_handler( + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/submissions\/count\/?" +) +class SubmissionCountHandler(ExtensionBaseHandler): + """ + Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/submissions/count. + """ + + @authenticated + async def get(self, lecture_id: int, assignment_id: int): + """ Returns the count of submissions made by the student for an assignment. + + :param lecture_id: id of the lecture + :type lecture_id: int + :param assignment_id: id of the assignment + :type assignment_id: int + """ + + try: + response = await self.request_service.request( + method="GET", + endpoint=f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/count", + header=self.grader_authentication_header, + response_callback=self.set_service_headers + ) + self.log.info(f"{response}") + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + self.write(json.dumps(response)) \ No newline at end of file diff --git a/grader_labextension/handlers/version_control.py b/grader_labextension/handlers/version_control.py index 5ea1d51..b0682ab 100644 --- a/grader_labextension/handlers/version_control.py +++ b/grader_labextension/handlers/version_control.py @@ -4,24 +4,26 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +from ast import List import json import os import shutil from http import HTTPStatus from urllib.parse import unquote, quote -from tornado.web import HTTPError +from tornado.web import HTTPError, authenticated +from grader_labextension.services.request import RequestServiceError from grader_service.convert.converters.base import GraderConvertException from grader_service.convert.converters.generate_assignment import GenerateAssignment from .base_handler import ExtensionBaseHandler, cache from ..api.models.submission import Submission from ..registry import register_handler from ..services.git import GitError, GitService -from tornado.httpclient import HTTPClientError, HTTPResponse +from tornado.httpclient import HTTPResponse @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/generate\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/generate\/?" ) class GenerateHandler(ExtensionBaseHandler): """ @@ -39,17 +41,17 @@ async def put(self, lecture_id: int, assignment_id: int): try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) code = lecture["code"] a_id = assignment["id"] @@ -66,6 +68,7 @@ async def put(self, lecture_id: int, assignment_id: int): copy_files=True, # Always copy files from source to release ) generator.force = True + generator.log = self.log try: # delete contents of output directory since we might have chosen to disallow files @@ -89,18 +92,59 @@ async def put(self, lecture_id: int, assignment_id: int): except OSError as e: self.log.error(f"Could delete {gradebook_path}! Error: {e.strerror}") self.log.info("GenerateAssignment conversion done") - self.write("OK") + self.write({"status": "OK"}) + + +@register_handler( + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/remote-file-status\/(?P\w*)\/?" +) +class GitRemoteFileStatusHandler(ExtensionBaseHandler): + """ + Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/remote-file-status/{repo}. + """ + + @authenticated + async def get(self, lecture_id: int, assignment_id: int, repo: str): + if repo not in {"assignment", "source", "release"}: + self.log.error(HTTPStatus.NOT_FOUND) + raise HTTPError( + HTTPStatus.NOT_FOUND, reason=f"Repository {repo} does not exist" + ) + lecture = await self.get_lecture(lecture_id) + assignment = await self.get_assignment(lecture_id, assignment_id) + file_path = self.get_query_argument('file') # Retrieve the file path from the query parameters + git_service = GitService( + server_root_dir=self.root_dir, + lecture_code=lecture["code"], + assignment_id=assignment["id"], + repo_type=repo, + config=self.config, + force_user_repo=True if repo == "release" else False, + ) + try: + if not git_service.is_git(): + git_service.init() + git_service.set_author(author=self.user_name) + git_service.set_remote(f"grader_{repo}") + git_service.fetch_all() + status = git_service.check_remote_file_status(file_path) + self.log.info(f"File {file_path} status: {status}") + except GitError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.error) + response = json.dumps({"status": status.name}) + self.write(response) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/remote-status\/(?P\w*)\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/remote-status\/(?P\w*)\/?" ) class GitRemoteStatusHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/remote_status/{repo}. """ - @cache(max_age=15) + @authenticated async def get(self, lecture_id: int, assignment_id: int, repo: str): if repo not in {"assignment", "source", "release"}: self.log.error(HTTPStatus.NOT_FOUND) @@ -126,19 +170,19 @@ async def get(self, lecture_id: int, assignment_id: int, repo: str): status = git_service.check_remote_status(f"grader_{repo}", "main") except GitError as e: self.log.error(e) - raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=str(e)) - self.write(status.name) + raise HTTPError(e.code, reason=e.error) + response = json.dumps({"status": status.name}) + self.write(response) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/log\/(?P\w*)\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/log\/(?P\w*)\/?" ) class GitLogHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/log/{repo}. """ - - @cache(max_age=15) + @authenticated async def get(self, lecture_id: int, assignment_id: int, repo: str): """ Sends a GET request to the grader service to get the logs of a given repo. @@ -157,17 +201,17 @@ async def get(self, lecture_id: int, assignment_id: int, repo: str): try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) git_service = GitService( server_root_dir=self.root_dir, @@ -189,19 +233,20 @@ async def get(self, lecture_id: int, assignment_id: int, repo: str): logs = [] except GitError as e: self.log.error(e) - raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=str(e)) + raise HTTPError(e.code, reason=e.error) self.write(json.dumps(logs)) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/pull\/(?P\w*)\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/pull\/(?P\w*)\/?" ) class PullHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/pull/{repo}. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int, repo: str): """Creates a local repository and pulls the specified repo type @@ -224,17 +269,17 @@ async def get(self, lecture_id: int, assignment_id: int, repo: str): try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) git_service = GitService( server_root_dir=self.root_dir, @@ -244,6 +289,7 @@ async def get(self, lecture_id: int, assignment_id: int, repo: str): config=self.config, force_user_repo=repo == "release", sub_id=sub_id, + log=self.log ) try: if not git_service.is_git(): @@ -251,14 +297,14 @@ async def get(self, lecture_id: int, assignment_id: int, repo: str): git_service.set_author(author=self.user_name) git_service.set_remote(f"grader_{repo}", sub_id=sub_id) git_service.pull(f"grader_{repo}", force=True) - self.write("OK") + self.write({"status": "OK"}) except GitError as e: self.log.error("GitError:\n" + e.error) - raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=e.error) + raise HTTPError(e.code, reason=e.error) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/push\/(?P\w*)\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/push\/(?P\w*)\/?" ) class PushHandler(ExtensionBaseHandler): """ @@ -280,6 +326,7 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): self.write_error(404) sub_id = self.get_argument("subid", None) commit_message = self.get_argument("commit-message", None) + selected_files = self.get_arguments("selected-files") submit = self.get_argument("submit", "false") == "true" # this username is used when an instructor creates a submission for a user (ignored otherwise) username = self.get_argument("for_user", None) @@ -291,17 +338,17 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) # differentiate between "normal" edit and "create" edit by sub_id -> if it is None we know we are in # submission creation mode instead of edit mode @@ -309,7 +356,7 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): submission = Submission(commit_hash="0" * 40) response: dict = await self.request_service.request( "POST", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions", body=submission.to_dict(), header=self.grader_authentication_header, ) @@ -319,7 +366,7 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): submission.edited = True await self.request_service.request( "PUT", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission.id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions/{submission.id}", body=submission.to_dict(), header=self.grader_authentication_header, ) @@ -345,7 +392,13 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): repo_type="source", config=self.config, ).path - git_service.copy_repo_contents(src=src_path) + + if(selected_files): + self.log.info(f"Selected files to push to {repo}: {selected_files}") + + git_service.copy_repo_contents(src=src_path, selected_files=selected_files) + + self.log.info(f"Files in {src_path} directory after copying selected files: {os.listdir(src_path)}") # call nbconvert before pushing generator = GenerateAssignment( @@ -355,6 +408,7 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): copy_files=True, # Always copy files from source to release ) generator.force = True + generator.log = self.log try: # delete contents of output directory since we might have chosen to disallow files @@ -373,8 +427,7 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): self.log.error( "Converting failed: Error converting notebook!", exc_info=True ) - - raise HTTPError(409, reason=str(e)) + raise HTTPError(HTTPStatus.CONFLICT, reason=str(e)) try: gradebook_path = os.path.join(git_service.path, "gradebook.json") self.log.info(f"Reading gradebook file: {gradebook_path}") @@ -383,14 +436,14 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): except FileNotFoundError: self.log.error(f"Cannot find gradebook file: {gradebook_path}") raise HTTPError( - HTTPStatus.INTERNAL_SERVER_ERROR, + HTTPStatus.NOT_FOUND, reason=f"Cannot find gradebook file: {gradebook_path}", ) self.log.info(f"Setting properties of assignment from {gradebook_path}") response: HTTPResponse = await self.request_service.request( "PUT", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/properties", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/properties", header=self.grader_authentication_header, body=gradebook_json, decode_response=False, @@ -410,7 +463,7 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): f"Cannot delete {gradebook_path}! Error: {e.strerror}\nAborting push!" ) raise HTTPError( - HTTPStatus.INTERNAL_SERVER_ERROR, + HTTPStatus.CONFLICT, reason=f"Cannot delete {gradebook_path}! Error: {e.strerror}\nAborting push!", ) @@ -425,20 +478,20 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): git_service.set_remote(f"grader_{repo}", sub_id=sub_id) except GitError as e: self.log.error("GitError:\n" + e.error) - raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=e.error) + raise HTTPError(e.code, reason=e.error) try: - git_service.commit(m=commit_message) + git_service.commit(message=commit_message, selected_files=selected_files) except GitError as e: self.log.error("GitError:\n" + e.error) - raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=e.error) + raise HTTPError(e.code, reason=e.error) try: git_service.push(f"grader_{repo}", force=True) except GitError as e: self.log.error("GitError:\n" + e.error) git_service.undo_commit() - raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=str(e.error)) + raise HTTPError(e.code, reason=str(e.error)) if submit and repo == "assignment": self.log.info(f"Submitting assignment {assignment_id}!") @@ -447,7 +500,7 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): submission = Submission(commit_hash=latest_commit_hash) response = await self.request_service.request( "POST", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/submissions", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/submissions", body=submission.to_dict(), header=self.grader_authentication_header, ) @@ -456,21 +509,22 @@ async def put(self, lecture_id: int, assignment_id: int, repo: str): except (KeyError, IndexError) as e: self.log.error(e) raise HTTPError(HTTPStatus.INTERNAL_SERVER_ERROR, reason=str(e)) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) - self.write("OK") + self.write({"status": "OK"}) @register_handler( - path=r"\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/reset\/?" + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/reset\/?" ) class ResetHandler(ExtensionBaseHandler): """ Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/reset. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int): """ Sends a GET request to the grader service that resets the user repo. @@ -482,13 +536,59 @@ async def get(self, lecture_id: int, assignment_id: int): try: await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}/reset", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}/reset", + header=self.grader_authentication_header, + ) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + self.write({"status": "OK"}) + + +@register_handler( + path=r"api\/lectures\/(?P\d*)\/assignments\/(?P\d*)\/restore\/(?P\w*)\/?" +) +class RestoreHandler(ExtensionBaseHandler): + @authenticated + async def get(self, lecture_id: int, assignment_id: int, commit_hash: str): + + try: + lecture = await self.request_service.request( + "GET", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: - self.log.error(e.response) - raise HTTPError(e.code, reason=e.response.reason) - self.write("OK") + assignment = await self.request_service.request( + "GET", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", + header=self.grader_authentication_header, + ) + except RequestServiceError as e: + self.log.error(e) + raise HTTPError(e.code, reason=e.message) + + git_service = GitService( + server_root_dir=self.root_dir, + lecture_code=lecture["code"], + assignment_id=assignment["id"], + repo_type="assignment", + config=self.config, + force_user_repo=False, + sub_id=None, + ) + try: + if not git_service.is_git(): + git_service.init() + git_service.set_author(author=self.user_name) + git_service.set_remote("grader_assignment") + # first reset by pull so there are no changes in the repository before reverting + git_service.pull("grader_assignment", force=True) + git_service.revert(commit_hash=commit_hash) + git_service.push("grader_assignment") + self.write({"status": "OK"}) + except GitError as e: + self.log.error("GitError:\n" + e.error) + raise HTTPError(e.code, reason=e.error) @register_handler( @@ -499,6 +599,7 @@ class NotebookAccessHandler(ExtensionBaseHandler): Tornado Handler class for http requests to /lectures/{lecture_id}/assignments/{assignment_id}/{notebook_name}. """ + @authenticated async def get(self, lecture_id: int, assignment_id: int, notebook_name: str): """ Sends a GET request to the grader service to access notebook and redirect to it. @@ -512,15 +613,15 @@ async def get(self, lecture_id: int, assignment_id: int, notebook_name: str): try: lecture = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}", + f"{self.service_base_url}api/lectures/{lecture_id}", header=self.grader_authentication_header, ) assignment = await self.request_service.request( "GET", - f"{self.service_base_url}/lectures/{lecture_id}/assignments/{assignment_id}", + f"{self.service_base_url}api/lectures/{lecture_id}/assignments/{assignment_id}", header=self.grader_authentication_header, ) - except HTTPClientError as e: + except RequestServiceError as e: self.set_status(e.code) self.write_error(e.code) return @@ -540,7 +641,7 @@ async def get(self, lecture_id: int, assignment_id: int, notebook_name: str): git_service.set_author(author=self.user_name) git_service.set_remote(f"grader_release") git_service.pull(f"grader_release", force=True) - self.write("OK") + self.write({"status": "OK"}) except GitError as e: self.log.error("GitError:\n" + e.error) self.write_error(400) diff --git a/grader_labextension/services/git.py b/grader_labextension/services/git.py index c0b6475..2f9287e 100644 --- a/grader_labextension/services/git.py +++ b/grader_labextension/services/git.py @@ -1,18 +1,14 @@ # Copyright (c) 2022, TU Wien # All rights reserved. # -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - import enum import logging import subprocess +from pathlib import Path from typing import List, Dict, Union, Tuple -from urllib.parse import urlparse, ParseResultBytes - +from urllib.parse import urlparse from traitlets.config.configurable import Configurable -from traitlets.config.loader import Config -from traitlets.traitlets import Int, TraitError, Unicode, validate +from traitlets.traitlets import Unicode import os import posixpath import shlex @@ -22,182 +18,173 @@ class GitError(Exception): - def __init__(self, error: str): + def __init__(self, code: int = 500, error: str = "Unknown Error"): + self.code = code self.error = error + super().__init__(error) - def __str__(self): - return self.error - - def __repr__(self) -> str: - return self.__str__() - - -class RemoteStatus(enum.Enum): - up_to_date = 1 - pull_needed = 2 - push_needed = 3 - divergent = 4 +class RemoteFileStatus(enum.Enum): + UP_TO_DATE = 1 + PULL_NEEDED = 2 + PUSH_NEEDED = 3 + DIVERGENT = 4 + NO_REMOTE_REPO = 5 class GitService(Configurable): - git_access_token = Unicode(os.environ.get("JUPYTERHUB_API_TOKEN"), allow_none=False).tag(config=True) - git_service_url = Unicode( - f'{os.environ.get("GRADER_HOST_URL", "http://127.0.0.1:4010")}{os.environ.get("GRADER_GIT_BASE_URL", "/services/grader/git")}', - allow_none=False).tag(config=True) + DEFAULT_HOST_URL = "http://127.0.0.1:4010" + DEFAULT_GIT_URL_PREFIX = "/services/grader/git" + _git_version = None + git_access_token = Unicode(os.environ.get("GRADER_API_TOKEN"), allow_none=False).tag(config=True) + git_service_url = Unicode(os.environ.get("GRADER_HOST_URL", DEFAULT_HOST_URL) + os.environ.get("GRADER_GIT_PREFIX", DEFAULT_GIT_URL_PREFIX), allow_none=False).tag(config=True) def __init__(self, server_root_dir: str, lecture_code: str, assignment_id: int, repo_type: str, - force_user_repo=False, sub_id=None, username=None, *args, **kwargs): + force_user_repo=False, sub_id=None, username=None, log=logging.getLogger('gitservice'), *args, **kwargs): super().__init__(*args, **kwargs) - self.log = logging.getLogger(str(self.__class__)) - self._git_version = None + self.log = log self.git_root_dir = server_root_dir self.lecture_code = lecture_code self.assignment_id = assignment_id self.repo_type = repo_type + + self.path = self._determine_repo_path(force_user_repo, sub_id, username) + os.makedirs(self.path, exist_ok=True) + + self._initialize_git_logging() + + def _determine_repo_path(self, force_user_repo: bool, sub_id: str, username: str) -> str: + """Determine the path for the git repository based on the type.""" if self.repo_type == "assignment" or force_user_repo: - self.path = os.path.join(self.git_root_dir, self.lecture_code, "assignments", str(self.assignment_id)) + return os.path.join(self.git_root_dir, self.lecture_code, "assignments", str(self.assignment_id)) elif self.repo_type == "edit": if username is not None: - self.path = os.path.join(self.git_root_dir, self.lecture_code, "create", str(self.assignment_id), username) + return os.path.join(self.git_root_dir, self.lecture_code, "create", str(self.assignment_id), username) else: - self.path = os.path.join(self.git_root_dir, self.lecture_code, self.repo_type, str(self.assignment_id), str(sub_id)) - else: - self.path = os.path.join(self.git_root_dir, self.lecture_code, self.repo_type, str(self.assignment_id)) - + return os.path.join(self.git_root_dir, self.lecture_code, self.repo_type, str(self.assignment_id), str(sub_id)) + return os.path.join(self.git_root_dir, self.lecture_code, self.repo_type, str(self.assignment_id)) + + def _initialize_git_logging(self): + """Initialize logging related to git configuration.""" self.log.info(f"New git service working in {self.path}") - os.makedirs(self.path, exist_ok=True) + self.git_http_scheme, self.git_remote_url = self._parse_git_service_url() + self.log.info(f"git_service_url: {self.git_service_url}") - self.log.info("git_access_token: " + self.git_access_token) + def _parse_git_service_url(self) -> Tuple[str, str]: + """Parse the git service URL into scheme and remote URL.""" url_parsed = urlparse(self.git_service_url) - self.log.info(f"git_service_url: " + self.git_service_url) - self.git_http_scheme: str = url_parsed.scheme - self.git_remote_url: str = url_parsed.netloc + url_parsed.path - self.log.info("git_http_scheme: " + self.git_http_scheme) - self.log.info("git_remote_url: " + self.git_remote_url) + return url_parsed.scheme, f"{url_parsed.netloc}{url_parsed.path}" - def push(self, origin: str, force=False): - """Pushes commits on the remote + def push(self, origin: str, force: bool = False): + """Push commits to the remote repository. Args: - origin (str): the remote - force (bool, optional): states if the operation should be forced. Defaults to False. + origin (str): The remote repository. + force (bool): Whether to force push. Defaults to False. """ - self.log.info(f"Pushing remote {origin} for {self.path}") + self.log.info(f"Pushing to remote {origin} at {self.path}") self._run_command(f"git push {origin} main" + (" --force" if force else ""), cwd=self.path) - def set_remote(self, origin: str, sub_id=None): - """Set a remote in the local repository + def set_remote(self, origin: str, sub_id: str = None): + """Set or update the remote repository. Args: - origin (str): the remote - sub_id ([type], optional): a query param for the feedback pull. Defaults to None. + origin (str): The remote name. + sub_id (str): Optional query parameter for feedback pull. """ self.log.info(f"Setting remote {origin} for {self.path}") url = posixpath.join(self.git_remote_url, self.lecture_code, str(self.assignment_id), self.repo_type) + try: - if sub_id is None: - self._run_command( - f"git remote add {origin} {self.git_http_scheme}://oauth:{self.git_access_token}@{url}", - cwd=self.path) - else: - self.log.info(f"Setting remote with sub_id {sub_id}") - self._run_command( - f"git remote add {origin} {self.git_http_scheme}://oauth:{self.git_access_token}@{posixpath.join(url, sub_id)}", - cwd=self.path) + self._run_command(f"git remote add {origin} {self._build_remote_url(url, sub_id)}", cwd=self.path) + except GitError: + self.log.warning(f"Remote {origin} already exists. Updating URL.") + self._run_command(f"git remote set-url {origin} {self._build_remote_url(url, sub_id)}", cwd=self.path) - except GitError as e: - self.log.error("GitError:\n" + e.error) - self.log.info(f"Remote set: Updating remote {origin} for {self.path}") - if sub_id is None: - self._run_command( - f"git remote set-url {origin} {self.git_http_scheme}://oauth:{self.git_access_token}@{url}", - cwd=self.path) - else: - self.log.info(f"Setting remote with sub_id {sub_id}") - self._run_command( - f"git remote set-url {origin} {self.git_http_scheme}://oauth:{self.git_access_token}@{posixpath.join(url, sub_id)}", - cwd=self.path) + def _build_remote_url(self, base_url: str, sub_id: str = None) -> str: + """Build the complete remote URL for the git repository. + + Args: + base_url (str): The base URL of the remote repository. + sub_id (str): Optional sub_id for the URL. - def delete_remote(self, origin: str): - raise NotImplementedError() + Returns: + str: The complete remote URL. + """ + return f"{self.git_http_scheme}://oauth:{self.git_access_token}@{posixpath.join(base_url, sub_id or '')}" def switch_branch(self, branch: str): - """Switches into another branch + """Switch to the specified branch. Args: - branch (str): the branch name + branch (str): The branch name. """ - self.log.info(f"Fetching all at path {self.path}") - self._run_command(f"git fetch --all", cwd=self.path) - self.log.info(f"Switching to branch {branch} at path {self.path}") + self.log.info(f"Fetching all branches at {self.path}") + self._run_command("git fetch --all", cwd=self.path) + self.log.info(f"Switching to branch {branch} at {self.path}") self._run_command(f"git checkout {branch}", cwd=self.path) def fetch_all(self): self.log.info(f"Fetching all at path {self.path}") self._run_command(f"git fetch --all", cwd=self.path) - - def go_to_commit(self, commit_hash): - self.log.info(f"Show commit with hash {commit_hash}") - self._run_command(f"git checkout {commit_hash}", cwd=self.path) - - def undo_commit(self, n: int = 1) -> None: - self.log.info(f"Undoing {n} commit(s)") - self._run_command(f"git reset --hard HEAD~{n}", cwd=self.path) - self._run_command(f"git gc", cwd=self.path) - - - def pull(self, origin: str, branch="main", force=False): - """Pulls a repository + + def pull(self, origin: str, branch: str = "main", force: bool = False): + """Pull changes from the remote repository. Args: - origin (str): the remote - branch (str, optional): the branch name. Defaults to "main". - force (bool, optional): states if the operation should be forced. Defaults to False. + origin (str): The remote repository. + branch (str): The branch to pull from. Defaults to "main". + force (bool): Whether to force the pull. Defaults to False. """ + self.log.info(f"Pulling from {origin}/{branch} at {self.path}") + if not self.remote_branch_exists(origin=origin, branch=branch): + raise GitError(404, "Remote repository not found. Please ensure your assignment is pushed to the repository before proceeding.") if force: - self.log.info(f"Pulling remote {origin}") - out = self._run_command( - f'sh -c "git clean -fd && git fetch {origin} && git reset --hard {origin}/{branch}"', cwd=self.path, - capture_output=True) - self.log.info(out) - # self._run_command(f'sh -c "git fetch --all && git reset --mixed {origin}/main"',cwd=self.path) + # clean local changes + command = "git clean -fd" + self._run_command(command, cwd=self.path) + # fetch info + command = f"git fetch {origin}" + self._run_command(command, cwd=self.path) + # reset to branch head + command = f"git reset --hard {origin}/{branch}" + self._run_command(command, cwd=self.path) else: - self._run_command(f"git pull {origin} {branch}", cwd=self.path) + # just pull the branch + command = f"git pull {origin} {branch}" + self._run_command(command, cwd=self.path) - def init(self, force=False): - """Initiates a local repository + def init(self, force: bool = False): + """Initialize a local repository. Args: - force (bool, optional): states if the operation should be forced. Defaults to False. + force (bool): Whether to force initialization. Defaults to False. """ if not self.is_git() or force: - self.log.info(f"Calling init for {self.path}") - if self.git_version < (2, 28): - self._run_command(f"git init", cwd=self.path) - self._run_command("git checkout -b main", cwd=self.path) - else: - self._run_command(f"git init -b main", cwd=self.path) + self.log.info(f"Initializing git repository at {self.path}") + command = "git init -b main" if self.git_version >= (2, 28) else "git init" + self._run_command(command, cwd=self.path) - def is_git(self): - """Checks if the directory is a local repository + def go_to_commit(self, commit_hash): + self.log.info(f"Show commit with hash {commit_hash}") + self._run_command(f"git checkout {commit_hash}", cwd=self.path) - Returns: - bool: states if the directory is a repository - """ - return os.path.exists(os.path.join(self.path, ".git")) + def undo_commit(self, n: int = 1) -> None: + self.log.info(f"Undoing {n} commit(s)") + self._run_command(f"git reset --hard HEAD~{n}", cwd=self.path) + self._run_command(f"git gc", cwd=self.path) - def commit(self, m=str(datetime.now())): - """Commits the staged changes + def revert(self, commit_hash: str): + self.log.info(f"Reverting to {commit_hash}") + self._run_command(f'git revert --no-commit {commit_hash}..HEAD', cwd=self.path) + self._run_command(f'git commit -m "reverting to {commit_hash}" --allow-empty', cwd=self.path) + + def is_git(self) -> bool: + """Check if the directory is a git repository. - Args: - m (str, optional): the commit message. Defaults to str(datetime.now()). + Returns: + bool: True if it's a git repository, False otherwise. """ - # self.log.info("Adding all files") - # self._run_command(f'git add -A', cwd=self.path) - # self.log.info("Committing repository") - # self._run_command(f'git commit -m "{m}"', cwd=self.path) - self.log.info(f"Adding all files and committing in {self.path}") - self._run_command(f'sh -c \'git add -A && git commit --allow-empty -m "{m}"\'', cwd=self.path) + return Path(self.path).joinpath(".git").exists() def set_author(self, author): # TODO: maybe ask user to specify their own choices @@ -215,6 +202,7 @@ def clone(self, origin: str, force=False): self.set_remote(origin=origin) self.pull(origin=origin, force=force) + def delete_repo_contents(self, include_git=False): """Deletes the contents of the git service @@ -231,60 +219,69 @@ def delete_repo_contents(self, include_git=False): self.log.info(f"Deleted {os.path.join(root, d)} from {self.git_root_dir}") # Note: dirs_exist_ok was only added in Python 3.8 - def copy_repo_contents(self, src: str): + def copy_repo_contents(self, src: str, selected_files: List[str] = None): """copies repo contents from src to the git path Args: src (str): path where the to be copied files reside """ - self.log.info(f"Copying repository contents from {src} to {self.path}") ignore = shutil.ignore_patterns(".git", "__pycache__") - if sys.version_info.major == 3 and sys.version_info.minor >= 8: - shutil.copytree(src, self.path, ignore=ignore, dirs_exist_ok=True) - else: + if selected_files: + self.log.info(f"Copying only selected files from {src} to {self.path}") for item in os.listdir(src): - s = os.path.join(src, item) - d = os.path.join(self.path, item) - if os.path.isdir(s): - shutil.copytree(s, d, ignore=ignore) - else: - shutil.copy2(s, d) - - def check_remote_status(self, origin: str, branch: str) -> RemoteStatus: + if item in selected_files: + s = os.path.join(src, item) + d = os.path.join(self.path, item) + if os.path.isdir(s): + shutil.copytree(s, d, ignore=ignore) + else: + shutil.copy2(s, d) + else: + self.log.info(f"Copying repository contents from {src} to {self.path}") + if sys.version_info.major == 3 and sys.version_info.minor >= 8: + shutil.copytree(src, self.path, ignore=ignore, dirs_exist_ok=True) + else: + for item in os.listdir(src): + s = os.path.join(src, item) + d = os.path.join(self.path, item) + if os.path.isdir(s): + shutil.copytree(s, d, ignore=ignore) + else: + shutil.copy2(s, d) + + def check_remote_status(self, origin: str, branch: str) -> RemoteFileStatus: untracked, added, modified, deleted = self.git_status(hidden_files=False) local_changes = len(untracked) > 0 or len(added) > 0 or len(modified) > 0 or len(deleted) > 0 if self.local_branch_exists(branch): - local = self._run_command(f"git rev-parse {branch}", cwd=self.path, capture_output=True).strip() + local = self._run_command(f"git rev-parse {branch}", cwd=self.path).strip() else: local = None if self.remote_branch_exists(origin, branch): - remote = self._run_command(f"git rev-parse {origin}/{branch}", cwd=self.path, capture_output=True).strip() + remote = self._run_command(f"git rev-parse {origin}/{branch}", cwd=self.path).strip() else: - if len(untracked) + len(added) + len(modified) == 0: - return RemoteStatus.up_to_date # if we don't have remote and no files we are up-to-date - return RemoteStatus.push_needed + return RemoteFileStatus.NO_REMOTE_REPO if local is None and remote: if local_changes: - return RemoteStatus.divergent - return RemoteStatus.pull_needed + return RemoteFileStatus.DIVERGENT + return RemoteFileStatus.PULL_NEEDED if local == remote: if local_changes: - return RemoteStatus.push_needed - return RemoteStatus.up_to_date + return RemoteFileStatus.PUSH_NEEDED + return RemoteFileStatus.UP_TO_DATE - base = self._run_command(f"git merge-base {branch} {origin}/{branch}", cwd=self.path, capture_output=True).strip() + base = self._run_command(f"git merge-base {branch} {origin}/{branch}", cwd=self.path).strip() if local == base: - return RemoteStatus.pull_needed + return RemoteFileStatus.PULL_NEEDED elif remote == base: - return RemoteStatus.push_needed + return RemoteFileStatus.PUSH_NEEDED else: - return RemoteStatus.divergent + return RemoteFileStatus.DIVERGENT def git_status(self, hidden_files: bool = False) -> Tuple[List[str], List[str], List[str], List[str]]: - files = self._run_command("git status --porcelain", cwd=self.path, capture_output=True) + files = self._run_command("git status --porcelain", cwd=self.path) untracked, added, modified, deleted = [], [], [], [] for line in files.splitlines(): k, v = line.split(maxsplit=1) @@ -299,28 +296,41 @@ def git_status(self, hidden_files: bool = False) -> Tuple[List[str], List[str], elif k == "D": deleted.append(v) return untracked, added, modified, deleted + + def check_remote_file_status(self, file_path: str) -> RemoteFileStatus: + file_status_list = self._run_command(f"git status --porcelain {file_path}", cwd=self.path).split(maxsplit=1) + # Extract the status character from the list + if file_status_list: + file_status = file_status_list[0] + else: + # If the list is empty, the file is up-to-date + return RemoteFileStatus.UP_TO_DATE + # Convert the file status to the corresponding enum value + if file_status in {"??", "M", "A", "D"}: + return RemoteFileStatus.PUSH_NEEDED + else: + return RemoteFileStatus.DIVERGENT def local_branch_exists(self, branch: str) -> bool: - ret_code = self._run_command(f"git rev-parse --quiet --verify {branch}", cwd=self.path, check=False).returncode - if ret_code == 0: - return True - else: + try: + self._run_command(f"git rev-parse --quiet --verify {branch}", cwd=self.path) + except GitError as e: return False + return True def remote_branch_exists(self, origin: str, branch: str) -> bool: - ret_code = self._run_command(f"git ls-remote --exit-code {origin} {branch}", cwd=self.path, - check=False).returncode - if ret_code == 0: - return True - else: + try: + self._run_command(f"git ls-remote --exit-code {origin} {branch}", cwd=self.path) + except GitError as e: return False + return True def get_log(self, history_count=10) -> List[Dict[str, str]]: """ Execute git log command & return the result. """ cmd = f'git log --pretty=format:%H%n%an%n%at%n%D%n%s -{history_count}' - my_output = self._run_command(cmd, cwd=self.path, capture_output=True) + my_output = self._run_command(cmd, cwd=self.path) result = [] line_array = my_output.splitlines() @@ -331,7 +341,7 @@ def get_log(self, history_count=10) -> List[Dict[str, str]]: commit = { "commit": line_array[i], "author": line_array[i + 1], - "date": datetime.utcfromtimestamp(int(line_array[i + 2])).isoformat("T", "milliseconds") + "Z", + "date": datetime.fromtimestamp(int(line_array[i + 2])).isoformat("T", "milliseconds") + "Z", # "date": line_array[i + 2], "ref": line_array[i + 3], "commit_msg": line_array[i + 4], @@ -354,37 +364,44 @@ def git_version(self): """ if self._git_version is None: try: - version = self._run_command("git --version", capture_output=True) + version = self._run_command("git --version", cwd=self.path) except GitError: return tuple() version = version.split(" ")[2] self._git_version = tuple([int(v) for v in version.split(".")]) return self._git_version - def _run_command(self, command, cwd=None, capture_output=False, check=True) -> Union[str, subprocess.CompletedProcess]: - """Starts a sub process and runs a cmd command + def commit(self, message: str = str(datetime.now()), selected_files: List[str] = None): + """Commit staged changes. Args: - command str: command that is getting run. - cwd (str, optional): states where the command is getting run. Defaults to None. - capture_output (bool, optional): states if output is getting saved. Defaults to False. - check (bool, optional): whether to raise a GitError if process fails. - Raises: - GitError: returns appropriate git error + message (str): The commit message. Defaults to the current datetime. + selected_files (List[str]): Specific files to commit. Defaults to None. + """ + if selected_files: + for file in selected_files: + self._run_command(f"git add {shlex.quote(file)}", cwd=self.path) + else: + self._run_command("git add .", cwd=self.path) - Returns: - str: command output + self.log.info(f"Committing changes with message: {message}") + self._run_command(f'git commit --allow-empty -m "{message}"', cwd=self.path) + + def _run_command(self, command: str, cwd: str) -> Union[str, None]: + """Run a shell command and return the output. + + Args: + command (str): The command to run. + cwd (str): The working directory for the command. + + Raises: + GitError: If the command fails. """ - ret = None try: - self.log.info(f"Running: {command}") - ret = subprocess.run(shlex.split(command), cwd=cwd, capture_output=True, text=True) - ret.check_returncode() - if capture_output: - return ret.stdout - else: - return ret + self.log.debug(f"Executing command: {command} in {cwd}") + result = subprocess.run(command, shell=True, check=True, cwd=cwd, text=True, capture_output=True) + return result.stdout except subprocess.CalledProcessError as e: - raise GitError(ret.stderr.replace("\n", "")) - except FileNotFoundError as e: - raise GitError(e.strerror) + error_message = f"Command '{command}' failed with error: {e.stderr}" + self.log.error(error_message) + raise GitError(500, error_message) diff --git a/grader_labextension/services/request.py b/grader_labextension/services/request.py index 0642202..f12bb61 100644 --- a/grader_labextension/services/request.py +++ b/grader_labextension/services/request.py @@ -1,28 +1,90 @@ -# Copyright (c) 2022, TU Wien -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -import logging, json - -from tornado.httpclient import AsyncHTTPClient, HTTPResponse, HTTPRequest -from traitlets.config.configurable import LoggingConfigurable, SingletonConfigurable -from typing import Dict, Union, Callable, Optional -from tornado.escape import json_decode -from traitlets.traitlets import Int, TraitError, Unicode, validate -from urllib.parse import urlencode, quote_plus, urlparse, ParseResultBytes import os +import json +import asyncio +from typing import Union, Dict, Callable, Optional +from urllib.parse import urlparse, ParseResultBytes, urlencode, quote_plus +from tornado.httpclient import AsyncHTTPClient, HTTPRequest, HTTPResponse, HTTPError +from traitlets import Unicode, TraitError, validate +from traitlets.config import SingletonConfigurable + +class RequestServiceError(Exception): + def __init__(self, code: int, status_text: str, message: str): + self.code = code + self.status_text = status_text + self.message = message + super().__init__(self.message) + + def __str__(self): + return f"[{self.code} {self.status_text}] {self.message}" class RequestService(SingletonConfigurable): url = Unicode(os.environ.get("GRADER_HOST_URL", "http://127.0.0.1:4010")) - def __init__(self, **kwargs): + def __init__( + self, + default_request_timeout: float = 20.0, + default_connect_timeout: float = 20.0, + max_retries: int = 3, # Max retry attempts for transient errors + **kwargs + ): super().__init__(**kwargs) - self.http_client = AsyncHTTPClient() + self.http_client = AsyncHTTPClient(max_clients=10) # Limit concurrency self._service_cookie = None + self.default_request_timeout = default_request_timeout + self.default_connect_timeout = default_connect_timeout + self.max_retries = max_retries + + def get_authorization_header(self): + auth_token = os.environ.get("GRADER_API_TOKEN") + if auth_token is None: + raise RequestServiceError(401, "Unauthorized", "No Grader API token found.") + return {"Authorization": f"Token {auth_token}"} + + async def request_with_retries( + self, + method: str, + endpoint: str, + body: Union[dict, str] = None, + header: Dict[str, str] = None, + decode_response: bool = True, + request_timeout: float = None, + connect_timeout: float = None, + max_retries: int = None, + retry_delay: float = 1.0, # Initial retry delay in seconds + backoff_factor: float = 2.0, # Factor to increase delay between retries + response_callback: Optional[Callable[[HTTPResponse], None]] = None + ) -> Union[dict, list, HTTPResponse]: + """ + Make an HTTP request with retry logic for transient errors. + """ + attempt = 0 + retries = max_retries or self.max_retries + + while attempt < retries: + try: + return await self.request( + method=method, + endpoint=endpoint, + body=body, + header=self.get_authorization_header(), + decode_response=decode_response, + request_timeout=request_timeout, + connect_timeout=connect_timeout, + response_callback=response_callback + ) + except (HTTPError, ConnectionRefusedError) as e: + if isinstance(e, HTTPError) and e.code not in {502, 503, 504}: + raise # Re-raise if it's not a retryable HTTP error + attempt += 1 + if attempt < retries: + retry_delay_seconds = retry_delay * (backoff_factor ** (attempt - 1)) + self.log.warning(f"Retry {attempt}/{retries} after {retry_delay_seconds}s due to error: {e}") + await asyncio.sleep(retry_delay_seconds) + else: + raise RequestServiceError(503, "Service Unavailable", "Max retries reached. Upstream service unavailable.") + async def request( self, method: str, @@ -30,58 +92,86 @@ async def request( body: Union[dict, str] = None, header: Dict[str, str] = None, decode_response: bool = True, - request_timeout: float = 20.0, - connect_timeout: float = 20.0, + request_timeout: float = None, + connect_timeout: float = None, response_callback: Optional[Callable[[HTTPResponse], None]] = None ) -> Union[dict, list, HTTPResponse]: - self.log.info(self.url + endpoint) - if self._service_cookie: - header["Cookie"] = self._service_cookie + """ + Core request function that handles the HTTP call. + """ + if header is None: + header = self.get_authorization_header() + + self.log.info(f"Requesting {method} {self.url + endpoint}") + request_timeout = request_timeout or self.default_request_timeout + connect_timeout = connect_timeout or self.default_connect_timeout + + header = self.prepare_headers(header) if isinstance(body, dict): body = json.dumps(body) - # Build HTTPRequest - request = HTTPRequest(url=self.url + endpoint, - method=method, - headers=header, - request_timeout=request_timeout, - connect_timeout=connect_timeout - ) - # Add body if exists - if body: - request.body = body - - # Sent HTTPRequest - response: HTTPResponse = await self.http_client.fetch(request=request) - - for cookie in response.headers.get_list("Set-Cookie"): - token = header.get("Authorization", None) - if token and token.startswith("Token "): - token = token[len("Token "):] + request = HTTPRequest( + url=self.url + endpoint, + method=method, + headers=header, + body=body if body else None, + request_timeout=request_timeout, + connect_timeout=connect_timeout + ) + + try: + response: HTTPResponse = await self.http_client.fetch(request=request) + self.log.info(f"Received response with status {response.code} from {response.effective_url}") + if decode_response: + response_data = json.loads(response.body) else: - continue - if cookie.startswith(token): - self._service_cookie = cookie + response_data = response - if response_callback: - response_callback(response) + if response_callback: + response_callback(response) - if decode_response: - return json_decode(response.body) - else: - return response + return response_data + except HTTPError as http_error: + self.log.error(f"HTTP error occurred: {http_error.response.reason}") + raise RequestServiceError( + http_error.code, + "Service Error", + http_error.response.reason or "An error occurred in the upstream service." + ) + + except ConnectionRefusedError: + self.log.error(f"Connection refused for {self.url + endpoint}") + raise RequestServiceError(502, "Bad Gateway", "Unable to connect to the upstream service.") + except Exception as e: + self.log.error(f"Unexpected error: {e}") + raise RequestServiceError(500, "Internal Server Error", f"An unexpected error occurred: {str(e)}") + + def prepare_headers(self, header: Dict[str, str] = None) -> Dict[str, str]: + """ + Prepares headers by adding service cookie or authorization if available. + """ + if header is None: + header = {} + if self._service_cookie: + header["Cookie"] = self._service_cookie + if "Authorization" not in header and os.getenv("SERVICE_TOKEN"): + header["Authorization"] = f"Bearer {os.getenv('SERVICE_TOKEN')}" + return header @validate("url") def _validate_url(self, proposal): url = proposal["value"] result: ParseResultBytes = urlparse(url) if not all([result.scheme, result.hostname]): - raise TraitError("Invalid url: at least has to contain scheme and hostname") + raise TraitError("Invalid URL: must contain both scheme and hostname") return url @staticmethod def get_query_string(params: dict) -> str: + """ + Helper to build query strings from a dictionary of parameters. + """ d = {k: v for k, v in params.items() if v is not None} query_params: str = urlencode(d, quote_via=quote_plus) - return "?" + query_params if query_params != "" else "" + return "?" + query_params if query_params else "" diff --git a/package.json b/package.json index 41c97e5..8305edc 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,25 @@ { "name": "grader-labextension", - "version": "0.4.0", + "version": "0.6.1", "description": "Grader Labextension is a JupyterLab extension to enable automatic grading of assignment notebooks.", "keywords": [ "jupyter", "jupyterlab", "jupyterlab-extension" ], - "homepage": "https://github.com/TU-Wien-dataLAB/Grader-Labextension", + "homepage": "https://github.com/TU-Wien-dataLAB/grader-labextension", "bugs": { - "url": "https://github.com/TU-Wien-dataLAB/Grader-Labextension/issues" + "url": "https://github.com/TU-Wien-dataLAB/grader-labextension/issues" }, "license": "BSD-3-Clause", "author": { - "name": "Florian Jaeger", + "name": "Florian Jaeger, Marijana Petojevic, Matthias Matt", "email": "datalab@tuwien.ac.at" }, "files": [ "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}", + "src/**/*.{ts,tsx}", "schema/*.json" ], "main": "lib/index.js", @@ -26,7 +27,7 @@ "style": "style/index.css", "repository": { "type": "git", - "url": "https://github.com/TU-Wien-dataLAB/Grader-Labextension.git" + "url": "https://github.com/TU-Wien-dataLAB/grader-labextension.git" }, "scripts": { "build": "jlpm build:lib && jlpm build:labextension:dev", @@ -73,6 +74,7 @@ "@mui/material": "^5.13.4", "@mui/system": "^5.13.2", "@mui/x-date-pickers": "^6.19.0", + "@tanstack/react-query": "^5.27.5", "@types/d3-shape": "^3.1.1", "date-fns": "^3.2.0", "formik": "^2.4.1", @@ -91,6 +93,7 @@ "devDependencies": { "@jupyterlab/builder": "^4.0.0", "@jupyterlab/testutils": "^4.0.0", + "@tanstack/react-query-devtools": "^5.49.2", "@types/d3-scale": "^4.0.3", "@types/jest": "^29.2.0", "@types/json-schema": "^7.0.11", diff --git a/pyproject.toml b/pyproject.toml index 6c3c452..1cdf0bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "urllib3>=1.26.6", "traitlets>=5.0.5", "tornado>=6.2", - "grader-service>=0.4.0", + "grader-service>=0.6.0", "hatch>=1.7", "hatch-jupyter-builder>=0.5", "hatch-nodejs-version", @@ -45,7 +45,7 @@ test = [ ] [tool.tbump.version] -current = "0.4.0" +current = "0.6.1" regex = ''' (?P\d+) @@ -73,7 +73,7 @@ search = "grader-labextension=={current_version}" source = "nodejs" [tool.hatch.metadata.hooks.nodejs] -fields = ["description", "authors", "urls"] +fields = ["description", "authors", "urls", "keywords"] [tool.hatch.build.targets.sdist] artifacts = ["grader_labextension/labextension"] diff --git a/src/components/assignment/assignment-status.tsx b/src/components/assignment/assignment-status.tsx index 9b75996..b0feaf1 100644 --- a/src/components/assignment/assignment-status.tsx +++ b/src/components/assignment/assignment-status.tsx @@ -23,7 +23,6 @@ import ChatRoundedIcon from '@mui/icons-material/ChatRounded'; * Props for AssignmentComponent. */ export interface IAssignmentStatusProps { - //assignment: Assignment; submissions: Submission[]; activeStep: number; } @@ -40,9 +39,9 @@ export const AssignmentStatus = (props: IAssignmentStatusProps) => { description: ( You pulled from the release repository and can now work on the - assignment. If you are happy with your solution you can submit it. - Before the deadline you can always resubmit until you are happy with - the solution. + assignment. If you are happy with your solution, you can submit it. + Before the deadline, you can always resubmit until you are satisfied + with the solution. ) }, @@ -51,8 +50,8 @@ export const AssignmentStatus = (props: IAssignmentStatusProps) => { description: ( You have submitted the assignment {props.submissions.length} time - {props.submissions.length == 1 ? '' : 's'}. The instructor can review - each submission, but will most likely prioritise the latest. + {props.submissions.length === 1 ? '' : 's'}. The instructor can review + each submission but will most likely prioritize the latest one. ) }, @@ -61,15 +60,14 @@ export const AssignmentStatus = (props: IAssignmentStatusProps) => { description: ( You received feedback for one or more of your submissions! You can - view the feedback in the list of submission when clicking on the{' '} - icon. Within the deadline you - can make more submissions, regardless of whether you already received + view the feedback in the list of submissions by clicking on the{' '} + icon. Before the deadline, you + can continue making submissions, even if you have already received feedback. ) } ]; - //getActiveStep().then(s => setActiveStep(s)); return ( diff --git a/src/components/assignment/assignment.tsx b/src/components/assignment/assignment.tsx index b2cc18a..6d33ef1 100644 --- a/src/components/assignment/assignment.tsx +++ b/src/components/assignment/assignment.tsx @@ -11,8 +11,10 @@ import { Submission } from '../../model/submission'; import { Box, Button, + Card, Chip, IconButton, + LinearProgress, Stack, Tooltip, Typography @@ -22,8 +24,9 @@ import { SubmissionList } from './submission-list'; import { AssignmentStatus } from './assignment-status'; import { Files } from './files/files'; import WarningIcon from '@mui/icons-material/Warning'; -import { Outlet, useNavigate, useRouteLoaderData } from 'react-router-dom'; +import { Outlet } from 'react-router-dom'; import { + getAssignment, getAssignmentProperties, pullAssignment, pushAssignment, @@ -32,7 +35,7 @@ import { import { getFiles, lectureBasePath } from '../../services/file.service'; import { getAllSubmissions, - getProperties, + getSubmissionCount, submitAssignment } from '../../services/submissions.service'; import { enqueueSnackbar } from 'notistack'; @@ -48,6 +51,9 @@ import { openBrowser } from '../coursemanage/overview/util'; import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'; import { Scope, UserPermissions } from '../../services/permission.service'; import { GradeBook } from '../../services/gradebook'; +import { useQuery } from '@tanstack/react-query'; +import { getLecture } from '../../services/lectures.service'; +import { extractIdsFromBreadcrumbs } from '../util/breadcrumbs'; const calculateActiveStep = (submissions: Submission[]) => { const hasFeedback = submissions.reduce( @@ -69,6 +75,7 @@ const calculateActiveStep = (submissions: Submission[]) => { interface ISubmissionsLeft { subLeft: number; } + const SubmissionsLeftChip = (props: ISubmissionsLeft) => { const output = props.subLeft + ' submission' + (props.subLeft === 1 ? ' left' : 's left'); @@ -81,60 +88,91 @@ const SubmissionsLeftChip = (props: ISubmissionsLeft) => { * Renders the components available in the extended assignment modal view */ export const AssignmentComponent = () => { - const navigate = useNavigate(); - const reloadPage = () => navigate(0); - - const { lecture, assignment, submissions } = useRouteLoaderData( - 'assignment' - ) as { - lecture: Lecture; - assignment: Assignment; - submissions: Submission[]; - }; + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); - const [fileList, setFileList] = React.useState([] as string[]); + const { data: lecture, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId), + enabled: !!lectureId + }); - React.useEffect(() => { - getAssignmentProperties(lecture.id, assignment.id).then(properties => { - const gb = new GradeBook(properties); - setFileList([ - ...gb.getNotebooks().map(n => n + '.ipynb'), - ...gb.getExtraFiles() - ]); + const { data: assignment, isLoading: isLoadingAssignment } = + useQuery({ + queryKey: ['assignment', assignmentId], + queryFn: () => getAssignment(lectureId, assignmentId), + enabled: !!lectureId && !!assignmentId }); - }, []); - const path = `${lectureBasePath}${lecture.code}/assignments/${assignment.id}`; + const { data: submissions = [], refetch: refetchSubmissions } = useQuery< + Submission[] + >({ + queryKey: ['submissionsAssignmentStudent', lectureId, assignmentId], + queryFn: () => getAllSubmissions(lectureId, assignmentId, 'none', false), + enabled: !!lectureId && !!assignmentId + }); - /* Now we can divvy this into a useReducer */ - const [allSubmissions, setSubmissions] = React.useState(submissions); - const [files, setFiles] = React.useState([]); + const [fileList, setFileList] = React.useState([]); const [activeStatus, setActiveStatus] = React.useState(0); - const [subLeft, setSubLeft] = React.useState(0); + + const { + data: subLeft, + isLoading: isLoadingSubLeft, + refetch: refetchSubleft + } = useQuery({ + queryKey: ['subLeft'], + queryFn: async () => { + await refetchSubmissions(); + const response = await getSubmissionCount(lectureId, assignmentId); + const remainingSubmissions = + assignment.max_submissions - response.submission_count; + return remainingSubmissions <= 0 ? 0 : remainingSubmissions; + } + }); + + const { + data: files, + refetch: refetchFiles, + isLoading: isLoadingFiles + } = useQuery({ + queryKey: ['files', lectureId, assignmentId], + queryFn: () => + getFiles( + `${lectureBasePath}${lecture?.code}/assignments/${assignmentId}` + ), + enabled: !!lecture && !!assignment + }); React.useEffect(() => { - getAllSubmissions(lecture.id, assignment.id, 'none', false).then( - response => { - setSubmissions(response); - if (assignment.max_submissions - response.length < 0) { - setSubLeft(0); - } else { - setSubLeft(assignment.max_submissions - response.length); - } - } + if (lecture && assignment) { + getAssignmentProperties(lecture.id, assignment.id).then(properties => { + const gb = new GradeBook(properties); + setFileList([ + ...gb.getNotebooks().map(n => n + '.ipynb'), + ...gb.getExtraFiles() + ]); + const active_step = calculateActiveStep(submissions); + setActiveStatus(active_step); + refetchSubleft(); + }); + } + }, [lecture, assignment]); + + if ( + isLoadingAssignment || + isLoadingLecture || + isLoadingFiles || + isLoadingSubLeft + ) { + return ( + + + + + ); - getFiles(path).then(files => { - // TODO: make it really explicit where & who pulls the asssignment - // files! - //if (files.length === 0) { - // pullAssignment(lecture.id, assignment.id, 'assignment'); - //} - setFiles(files); - }); + } - const active_step = calculateActiveStep(submissions); - setActiveStatus(active_step); - }, []); + const path = `${lectureBasePath}${lecture.code}/assignments/${assignment.id}`; const resetAssignmentHandler = async () => { showDialog( @@ -153,7 +191,7 @@ export const AssignmentComponent = () => { enqueueSnackbar('Successfully Reset Assignment', { variant: 'success' }); - reloadPage(); + await refetchFiles(); } catch (e) { if (e instanceof Error) { enqueueSnackbar('Error Reset Assignment: ' + e.message, { @@ -176,14 +214,11 @@ export const AssignmentComponent = () => { 'This action will submit your current notebooks!', async () => { await submitAssignment(lecture, assignment, true).then( - response => { - console.log('Submitted'); - setSubmissions([response, ...allSubmissions]); - if (subLeft - 1 < 0) { - setSubLeft(0); - } else { - setSubLeft(subLeft - 1); - } + () => { + refetchSubleft().then(() => { + const active_step = calculateActiveStep(submissions); + setActiveStatus(active_step); + }); enqueueSnackbar('Successfully Submitted Assignment', { variant: 'success' }); @@ -210,6 +245,7 @@ export const AssignmentComponent = () => { }) ); }; + /** * Pulls from given repository by sending a request to the grader git service. * @param repo input which repository should be fetched @@ -220,11 +256,7 @@ export const AssignmentComponent = () => { enqueueSnackbar('Successfully Pulled Repo', { variant: 'success' }); - getFiles( - `${lectureBasePath}${lecture.code}/assignments/${assignment.id}` - ).then(files => { - setFiles(files); - }); + refetchFiles(); }, error => { enqueueSnackbar(error.message, { @@ -235,7 +267,7 @@ export const AssignmentComponent = () => { }; const isDeadlineOver = () => { - if (assignment.due_date === null) { + if (!assignment.due_date) { return false; } const time = new Date(assignment.due_date).getTime(); @@ -243,13 +275,17 @@ export const AssignmentComponent = () => { }; const isLateSubmissionOver = () => { - if (assignment.due_date === null) { + if (!assignment.due_date) { return false; } - let late_submission = assignment.settings.late_submission; - if (late_submission === null || late_submission.length === 0) { - late_submission = [{ period: 'P0D', scaling: undefined }]; + const late_submission = assignment.settings.late_submission || [ + { period: 'P0D', scaling: undefined } + ]; + // no late_submission entry found + if (late_submission.length === 0) { + return false; } + const late = moment(assignment.due_date) .add(moment.duration(late_submission[late_submission.length - 1].period)) .toDate() @@ -262,11 +298,10 @@ export const AssignmentComponent = () => { }; const isMaxSubmissionReached = () => { - if (assignment.max_submissions === null) { - return false; - } else { - return assignment.max_submissions <= submissions.length; - } + return ( + assignment.max_submissions !== null && + assignment.max_submissions <= submissions.length + ); }; const isAssignmentFetched = () => { @@ -278,11 +313,6 @@ export const AssignmentComponent = () => { const scope = permissions[lecture.code]; return scope >= Scope.tutor; }; - const [reloadFilesToggle, setReloadFiles] = React.useState(false); - - const reloadFiles = () => { - setReloadFiles(!reloadFilesToggle); - }; return ( @@ -314,7 +344,7 @@ export const AssignmentComponent = () => { Files - reloadFiles()}> + refetchFiles()}> @@ -371,7 +401,7 @@ export const AssignmentComponent = () => { : isLateSubmissionOver() || isMaxSubmissionReached() || isAssignmentCompleted() || - files.length == 0 + files.length === 0 } onClick={() => submitAssignmentHandler()} > @@ -425,7 +455,13 @@ export const AssignmentComponent = () => { ) ) : null} - + ); diff --git a/src/components/assignment/assignmentmanage.component.tsx b/src/components/assignment/assignmentmanage.component.tsx index 99bcf98..5686ad2 100644 --- a/src/components/assignment/assignmentmanage.component.tsx +++ b/src/components/assignment/assignmentmanage.component.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { useState } from 'react'; import { Lecture } from '../../model/lecture'; -import { useNavigate, useRouteLoaderData } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { FormControlLabel, FormGroup, @@ -19,6 +19,8 @@ import { } from '@mui/material'; import Box from '@mui/material/Box'; import { ButtonTr, GraderTable } from '../util/table'; +import { useQuery } from '@tanstack/react-query'; +import { getAllLectures } from '../../services/lectures.service'; interface ILectureTableProps { rows: Lecture[]; @@ -45,7 +47,11 @@ const LectureTable = (props: ILectureTableProps) => { {row.id} - {row.name} + + + {row.name} + + {row.code} ); @@ -59,14 +65,28 @@ const LectureTable = (props: ILectureTableProps) => { * @param props Props of the lecture file components */ export const AssignmentManageComponent = () => { - const allLectures = useRouteLoaderData('root') as { - lectures: Lecture[]; - completedLectures: Lecture[]; - }; + const { data: lectures, isLoading: isLoadingOngoingLectures } = useQuery({ + queryKey: ['lectures'], + queryFn: () => getAllLectures(false) + }); + + const { data: completedLectures, isLoading: isLoadingCompletedLectures } = useQuery({ + queryKey: ['completedLectures'], + queryFn: () => getAllLectures(true) + }); + const [showComplete, setShowComplete] = useState(false); + if (isLoadingCompletedLectures || isLoadingOngoingLectures) { + return Loading... + } + + return ( + + Assignments + Lectures @@ -86,7 +106,7 @@ export const AssignmentManageComponent = () => { diff --git a/src/components/assignment/feedback.tsx b/src/components/assignment/feedback.tsx index 791d080..4387c10 100644 --- a/src/components/assignment/feedback.tsx +++ b/src/components/assignment/feedback.tsx @@ -11,7 +11,9 @@ import { Lecture } from '../../model/lecture'; import { Assignment } from '../../model/assignment'; import { Submission } from '../../model/submission'; import { + getAllSubmissions, getProperties, + getSubmission, pullFeedback } from '../../services/submissions.service'; import { GradeBook } from '../../services/gradebook'; @@ -19,38 +21,62 @@ import { FilesList } from '../util/file-list'; import { openBrowser } from '../coursemanage/overview/util'; import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'; import { getFiles, lectureBasePath } from '../../services/file.service'; -import { Link, useParams, useRouteLoaderData } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getLecture } from '../../services/lectures.service'; +import { getAssignment } from '../../services/assignments.service'; +import { extractIdsFromBreadcrumbs } from '../util/breadcrumbs'; export const Feedback = () => { - const { lecture, assignment, submissions } = useRouteLoaderData( - 'assignment' - ) as { - lecture: Lecture; - assignment: Assignment; - submissions: Submission[]; - }; - const assignmentLink = `/lecture/${lecture.id}/assignment/${assignment.id}`; - + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); const params = useParams(); const submissionId = +params['sid']; - const submission = submissions.find(s => s.id === submissionId); - const [gradeBook, setGradeBook] = React.useState(null); - const [path, setPath] = React.useState(null); + const { data: lecture, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId), + enabled: !!lectureId + }); + + const { data: assignment, isLoading: isLoadingAssignment } = useQuery({ + queryKey: ['assignment', assignmentId], + queryFn: () => getAssignment(lectureId, assignmentId), + enabled: !!lecture && !!assignmentId + }); + + const { data: submission, isLoading: isLoadingSubmission } = useQuery({ + queryKey: ['submission', lectureId, assignmentId, submissionId], + queryFn: () => getSubmission(lectureId, assignmentId, submissionId), + enabled: !!lecture && !!assignment + }); - const feedbackPath = `${lectureBasePath}${lecture.code}/feedback/${assignment.id}/${submission.id}`; - getFiles(feedbackPath).then(files => { - if (files.length > 0) { - setPath(feedbackPath); - } + const { data: gradeBook, refetch: refetchGradeBook } = useQuery({ + queryKey: ['gradeBook', submissionId], + queryFn: () => submission ? getProperties(lectureId, assignmentId, submissionId).then(properties => new GradeBook(properties)) : Promise.resolve(null), + enabled: !!submission }); + const feedbackPath = `${lectureBasePath}${lecture?.code}/feedback/${assignmentId}/${submissionId}`; + const { data: submissionFiles, refetch: refetchSubmissionFiles } = useQuery({ + queryKey: ['submissionFiles', feedbackPath], + queryFn: () => feedbackPath ? getFiles(feedbackPath) : Promise.resolve([]), + enabled: !!feedbackPath + }); + + + const reloadProperties = async () => { + await refetchGradeBook(); + }; + React.useEffect(() => { - getProperties(lecture.id, assignment.id, submission.id).then(properties => { - const gradeBook = new GradeBook(properties); - setGradeBook(gradeBook); - }); - }, [lecture, assignment, submission]); + reloadProperties(); + }, []); + + if (isLoadingAssignment || isLoadingLecture || isLoadingSubmission) { + return Loading...; + } + + const assignmentLink = `/lecture/${lecture.id}/assignment/${assignment.id}`; return ( @@ -134,7 +160,13 @@ export const Feedback = () => { Feedback Files - + @@ -145,20 +177,20 @@ export const Feedback = () => { size="small" color={'primary'} onClick={() => { - pullFeedback(lecture, assignment, submission).then(() => { - setPath(feedbackPath); + pullFeedback(lecture, assignment, submission).then(async () => { + await refetchSubmissionFiles(); }); }} > Pull Feedback - {path !== null && ( + {feedbackPath !== null && ( openBrowser(path)} + onClick={() => openBrowser(feedbackPath)} > Show in Filebrowser diff --git a/src/components/assignment/files/files.tsx b/src/components/assignment/files/files.tsx index 17b8660..3b3af89 100644 --- a/src/components/assignment/files/files.tsx +++ b/src/components/assignment/files/files.tsx @@ -39,6 +39,7 @@ export const Files = (props: IFilesProps) => { lecture={props.lecture} shouldContain={props.files} assignment={props.assignment} + checkboxes={false} /> ); diff --git a/src/components/assignment/lecture.tsx b/src/components/assignment/lecture.tsx index bc972e4..5778d1c 100644 --- a/src/components/assignment/lecture.tsx +++ b/src/components/assignment/lecture.tsx @@ -4,22 +4,15 @@ // This source code is licensed under the BSD-style license found in the // LICENSE file in the root directory of this source tree. import * as React from 'react'; -import moment from 'moment'; +import { useNavigate } from 'react-router-dom'; import { - useNavigate, - useNavigation, - useRouteLoaderData -} from 'react-router-dom'; -import { - Button, IconButton, Card, LinearProgress, Stack, TableCell, TableRow, - Typography, - Box + Typography } from '@mui/material'; import { red, blue, green, grey } from '@mui/material/colors'; import RestartAltIcon from '@mui/icons-material/RestartAlt'; @@ -31,12 +24,13 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload'; import { enqueueSnackbar } from 'notistack'; import { ButtonTr, GraderTable, headerWidth, IHeaderCell } from '../util/table'; -import { DeadlineComponent, getDisplayDate } from '../util/deadline'; +import { DeadlineComponent } from '../util/deadline'; import { Assignment } from '../../model/assignment'; import { AssignmentDetail } from '../../model/assignmentDetail'; import { Submission } from '../../model/submission'; import { Lecture } from '../../model/lecture'; import { + getAllAssignments, pullAssignment, pushAssignment, resetAssignment @@ -44,6 +38,9 @@ import { import { showDialog } from '../util/dialog-provider'; import EditOffIcon from '@mui/icons-material/EditOff'; import { getFiles, lectureBasePath } from '../../services/file.service'; +import { useQuery } from '@tanstack/react-query'; +import { getLecture } from '../../services/lectures.service'; +import { extractIdsFromBreadcrumbs } from '../util/breadcrumbs'; /* * Buttons for AssignmentTable @@ -73,7 +70,6 @@ const EditButton = (props: IEditProps) => { getFiles( `${lectureBasePath}${props.lecture.code}/assignments/${props.assignment.id}` ).then(files => { - console.log(files); if (files.length > 0) { setAssignmentPulled(true); } @@ -329,21 +325,30 @@ const transformAssignments = ( * Renders the lecture card which contains its assignments. */ export const LectureComponent = () => { - const { lecture, assignments } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: AssignmentDetail[]; - }; + const { lectureId } = extractIdsFromBreadcrumbs(); - const navigation = useNavigation(); + const { data: lecture, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId, true), + enabled: !!lectureId + }); - const newAssignmentSubmissions = transformAssignments(assignments); + const { data: assignments = [], isLoading: isLoadingAssignments } = useQuery({ + queryKey: ['assignments', lectureId], + queryFn: () => getAllAssignments(lectureId), + enabled: !!lecture + }); - const [lectureState, setLecture] = React.useState(lecture); - const [assignmentsState, setAssignments] = React.useState( - newAssignmentSubmissions - ); + const [assignmentsState, setAssignmentsState] = React.useState([]); - if (navigation.state === 'loading') { + React.useEffect(() => { + if (assignments.length > 0) { + const transformedAssignments = transformAssignments(assignments); + setAssignmentsState(transformedAssignments); + } + }, [assignments]); + + if (isLoadingLecture || isLoadingAssignments) { return ( @@ -353,15 +358,11 @@ export const LectureComponent = () => { ); } - /* - - */ - return ( - {lectureState.name} - {lectureState.complete ? ( + {lecture.name} + {lecture.complete ? ( { Assignments - + ); }; diff --git a/src/components/assignment/routes.tsx b/src/components/assignment/routes.tsx index 789155a..d8185af 100644 --- a/src/components/assignment/routes.tsx +++ b/src/components/assignment/routes.tsx @@ -10,15 +10,8 @@ import { LinkRouter, Page } from '../util/breadcrumbs'; import ErrorPage from '../util/error'; import { UserPermissions } from '../../services/permission.service'; -import { - getAllLectures, - getLecture, - getUsers -} from '../../services/lectures.service'; -import { - getAllAssignments, - getAssignment -} from '../../services/assignments.service'; +import { getAllLectures, getLecture } from '../../services/lectures.service'; +import { getAssignment } from '../../services/assignments.service'; import { getAllSubmissions } from '../../services/submissions.service'; import { enqueueSnackbar } from 'notistack'; @@ -28,6 +21,7 @@ import { AssignmentManageComponent } from './assignmentmanage.component'; import { LectureComponent } from './lecture'; import { AssignmentComponent } from './assignment'; import { Feedback } from './feedback'; +import { QueryClient } from '@tanstack/react-query'; export const loadPermissions = async () => { try { @@ -45,19 +39,33 @@ export const loadPermissions = async () => { } }; -export const loadLecture = async (lectureId: number) => { - try { - const [lecture, assignments] = await Promise.all([ - getLecture(lectureId), - getAllAssignments(lectureId, false, true) - ]); - return { lecture, assignments }; - } catch (error: any) { - enqueueSnackbar(error.message, { - variant: 'error' - }); - throw new Error('Could not load data!'); - } +export const loadLecture = async ( + lectureId: number, + queryClient: QueryClient +) => { + const query = { + queryKey: ['lecture', lectureId], + queryFn: async () => getLecture(lectureId) + }; + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ); +}; + +export const loadAssignment = async ( + lectureId: number, + assignmentId: number, + queryClient: QueryClient +) => { + const query = { + queryKey: ['assignment', lectureId, assignmentId], + queryFn: async () => getAssignment(lectureId, assignmentId) + }; + return ( + queryClient.getQueryData(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ); }; /* @@ -88,25 +96,6 @@ export const loadSubmissions = async ( } }; -export const loadAssignment = async ( - lectureId: number, - assignmentId: number -) => { - try { - const [lecture, assignment, submissions] = await Promise.all([ - getLecture(lectureId), - getAssignment(lectureId, assignmentId), - getAllSubmissions(lectureId, assignmentId, 'none', false) - ]); - return { lecture, assignment, submissions }; - } catch (error: any) { - enqueueSnackbar(error.message, { - variant: 'error' - }); - throw error; - } -}; - function ExamplePage({ to }) { const navigation = useNavigation(); // router navigates to new route (and loads data) const loading = navigation.state === 'loading'; @@ -129,19 +118,7 @@ function ExamplePage({ to }) { ); } -// TODO: remove test code - -const testFetchAssignment = async (lectureId: number, assignmentId: number) => { - console.log(lectureId, assignmentId); - const { lecture } = await loadLecture(lectureId); - // throw lecture; - return { - assignment: { id: assignmentId, name: 'Introduction to Python' }, - lecture: lecture - }; -}; - -export const getRoutes = () => { +export const getRoutes = (queryClient: QueryClient) => { const routes = createRoutesFromElements( // this is a layout route without a path (see: https://reactrouter.com/en/main/start/concepts#layout-routes) { 'Lectures', link: params => '/' @@ -161,10 +138,10 @@ export const getRoutes = () => { loadLecture(+params.lid)} + loader={({ params }) => loadLecture(+params.lid, queryClient)} handle={{ // functions in handle have to handle undefined data (error page is displayed afterwards) - crumb: data => data?.lecture.name, + crumb: data => data?.name, link: params => `lecture/${params?.lid}/` }} > @@ -172,9 +149,11 @@ export const getRoutes = () => { loadAssignment(+params.lid, +params.aid)} + loader={({ params }) => + loadAssignment(+params.lid, +params.aid, queryClient) + } handle={{ - crumb: data => data?.assignment.name, + crumb: data => data?.name, link: params => `assignment/${params?.aid}/` }} > diff --git a/src/components/assignment/submission-list.tsx b/src/components/assignment/submission-list.tsx index ef4ddce..9606639 100644 --- a/src/components/assignment/submission-list.tsx +++ b/src/components/assignment/submission-list.tsx @@ -14,7 +14,8 @@ import { ListItemIcon, ListItemText, Paper, - Typography + Typography, + ListItemSecondaryAction } from '@mui/material'; import { SxProps } from '@mui/system'; import { Theme } from '@mui/material/styles'; @@ -25,14 +26,28 @@ import { utcToTimestamp } from '../../services/datetime.service'; import CloudDoneRoundedIcon from '@mui/icons-material/CloudDoneRounded'; +import RestoreIcon from '@mui/icons-material/Restore'; +import DeleteIcon from '@mui/icons-material/Delete'; import { grey } from '@mui/material/colors'; import { useNavigate } from 'react-router-dom'; +import { showDialog } from '../util/dialog-provider'; +import { + deleteSubmission, + restoreSubmission +} from '../../services/submissions.service'; +import { Assignment } from '../../model/assignment'; +import { Lecture } from '../../model/lecture'; +import { enqueueSnackbar } from 'notistack'; +import { queryClient } from '../../widgets/assignmentmanage'; /** * Props for SubmissionListComponent. */ interface ISubmissionListProps { + lecture: Lecture; + assignment: Assignment; submissions: Submission[]; + subLeft: number; sx?: SxProps; } @@ -57,21 +72,7 @@ export const SubmissionList = (props: ISubmissionListProps) => { ) .map(value => ( - } - size="small" - onClick={() => navigate(`feedback/${value.id}`)} - > - Open feedback - - ) : null - } - > + @@ -84,6 +85,109 @@ export const SubmissionList = (props: ISubmissionListProps) => { : null } /> + + { + } + size="small" + onClick={() => { + showDialog( + 'Restore Submission', + 'Do you really want to revert the assignment state to this submission? This deletes all current changes you made!', + async () => { + try { + await restoreSubmission( + props.lecture.id, + props.assignment.id, + value.commit_hash + ); + enqueueSnackbar('Successfully Restored Submission', { + variant: 'success' + }); + } catch (e) { + if (e instanceof Error) { + enqueueSnackbar( + 'Error Reset Assignment: ' + e.message, + { variant: 'error' } + ); + } else { + console.error( + 'Error: cannot interpret type unknown as error', + e + ); + } + } + } + ); + }} + > + Restore + + } + {value.feedback_status === 'not_generated' && ( + } + size="small" + onClick={() => { + const warningMessage = + props.assignment.max_submissions !== null && + props.subLeft === 0 && + props.submissions.length === 1 + ? 'This is your last submission that can be graded. If you delete it, you won’t be able to submit again, and you will receive 0 points.' + : ''; + showDialog( + 'Delete Submission', + 'Are you sure you want to delete this submission? Once deleted, you cannot undo this action. This will not affect the number of submissions you have remaining, if a maximum number of submissions is allowed.' + + warningMessage, + async () => { + try { + await deleteSubmission( + props.lecture.id, + props.assignment.id, + value.id + ); + await queryClient.invalidateQueries({ + queryKey: ['submissions'] + }); + await queryClient.invalidateQueries({ + queryKey: ['submissionsAssignmentStudent'] + }); + enqueueSnackbar('Successfully Deleted Submission', { + variant: 'success' + }); + } catch (e) { + if (e instanceof Error) { + enqueueSnackbar( + 'Error Delete Submission: ' + e.message, + { variant: 'error' } + ); + } else { + console.error( + 'Error: cannot interpret type unkown as error', + e + ); + } + } + } + ); + }} + > + Delete Submission + + )} + {value.feedback_status === 'generated' || + value.feedback_status === 'feedback_outdated' ? ( + } + size="small" + onClick={() => navigate(`feedback/${value.id}`)} + > + Open feedback + + ) : null} + )); diff --git a/src/components/coursemanage/assignment-modal.tsx b/src/components/coursemanage/assignment-modal.tsx index 55048e0..c9170e1 100644 --- a/src/components/coursemanage/assignment-modal.tsx +++ b/src/components/coursemanage/assignment-modal.tsx @@ -11,16 +11,10 @@ import FolderIcon from '@mui/icons-material/Folder'; import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered'; import QueryStatsIcon from '@mui/icons-material/QueryStats'; import SettingsIcon from '@mui/icons-material/Settings'; -import { Assignment } from '../../model/assignment'; -import { Lecture } from '../../model/lecture'; -import { Submission } from '../../model/submission'; -import { - Link, - Outlet, - useMatch, - useParams, - useRouteLoaderData -} from 'react-router-dom'; +import { Link, Outlet, useMatch, useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getAllSubmissions } from '../../services/submissions.service'; +import { extractIdsFromBreadcrumbs } from '../util/breadcrumbs'; function a11yProps(index: any) { return { @@ -30,13 +24,20 @@ function a11yProps(index: any) { } export const AssignmentModalComponent = () => { - const { assignment, allSubmissions, latestSubmissions } = useRouteLoaderData( - 'assignment' - ) as { - assignment: Assignment; - allSubmissions: Submission[]; - latestSubmissions: Submission[]; - }; + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); + + const { data: latestSubmissionsNumber = 0 } = useQuery({ + queryKey: ['latestSubmissionsNumber', lectureId, assignmentId], + queryFn: async () => { + const submissions = await getAllSubmissions( + lectureId, + assignmentId, + 'latest' + ); + return submissions.length; + }, + enabled: !!lectureId && !!assignmentId + }); const params = useParams(); const match = useMatch(`/lecture/${params.lid}/assignment/${params.aid}/*`); @@ -101,8 +102,8 @@ export const AssignmentModalComponent = () => { icon={ diff --git a/src/components/coursemanage/coursemanage.component.tsx b/src/components/coursemanage/coursemanage.component.tsx index d4a3a1c..646a9bc 100644 --- a/src/components/coursemanage/coursemanage.component.tsx +++ b/src/components/coursemanage/coursemanage.component.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { useState } from 'react'; import { Lecture } from '../../model/lecture'; -import { useNavigate, useRouteLoaderData } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { FormControlLabel, FormGroup, @@ -17,9 +17,9 @@ import { TableRow, Typography } from '@mui/material'; -import Box from '@mui/material/Box'; import { ButtonTr, GraderTable } from '../util/table'; -import { SectionTitle } from '../util/section-title'; +import { getAllLectures } from '../../services/lectures.service'; +import { useQuery } from '@tanstack/react-query'; interface ILectureTableProps { rows: Lecture[]; @@ -60,12 +60,22 @@ const LectureTable = (props: ILectureTableProps) => { }; export const CourseManageComponent = () => { - const allLectures = useRouteLoaderData('root') as { - lectures: Lecture[]; - completedLectures: Lecture[]; - }; + const { data: lectures, isLoading: isLoadingOngoingLectures } = useQuery({ + queryKey: ['lectures'], + queryFn: () => getAllLectures(false) + }); + + const { data: completedLectures, isLoading: isLoadingCompletedLectures } = useQuery({ + queryKey: ['completedLectures'], + queryFn: () => getAllLectures(true) + }); + const [showComplete, setShowComplete] = useState(false); + if (isLoadingCompletedLectures || isLoadingOngoingLectures) { + return Loading... + } + return ( @@ -90,7 +100,7 @@ export const CourseManageComponent = () => { diff --git a/src/components/coursemanage/files/file-view.tsx b/src/components/coursemanage/files/file-view.tsx index 68ad0d6..0a7e539 100644 --- a/src/components/coursemanage/files/file-view.tsx +++ b/src/components/coursemanage/files/file-view.tsx @@ -1,34 +1,46 @@ -import { Box } from '@mui/material'; import { Files } from './files'; import * as React from 'react'; -import { useRouteLoaderData } from 'react-router-dom'; import { Lecture } from '../../../model/lecture'; import { Assignment } from '../../../model/assignment'; -import { Submission } from '../../../model/submission'; +import { getAssignment } from '../../../services/assignments.service'; +import { useQuery } from '@tanstack/react-query'; +import { extractIdsFromBreadcrumbs } from '../../util/breadcrumbs'; +import { getLecture } from '../../../services/lectures.service'; export const FileView = () => { - const { lecture, assignments, users } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: Assignment[]; - users: { instructors: string[]; tutors: string[]; students: string[] }; - }; - const { assignment, allSubmissions, latestSubmissions } = useRouteLoaderData( - 'assignment' - ) as { - assignment: Assignment; - allSubmissions: Submission[]; - latestSubmissions: Submission[]; - }; + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); + + const { data: lectureData, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId), + enabled: !!lectureId + }); + + const { + data: assignmentData, + refetch: refetchAssignment, + isLoading: isLoadingAssignment + } = useQuery({ + queryKey: ['assignment', assignmentId], + queryFn: () => getAssignment(lectureId, assignmentId), + enabled: !!lectureId && !!assignmentId + }); + + if (isLoadingLecture || isLoadingAssignment) { + return Loading...; + } + + const lecture = lectureData; + const assignment = assignmentData; - const [assignmentState, setAssignmentState] = React.useState(assignment); - const onAssignmentChange = (assignment: Assignment) => { - setAssignmentState(assignment); + const onAssignmentChange = async () => { + await refetchAssignment(); }; return ( ); diff --git a/src/components/coursemanage/files/files.tsx b/src/components/coursemanage/files/files.tsx index e1fe94f..86b4155 100644 --- a/src/components/coursemanage/files/files.tsx +++ b/src/components/coursemanage/files/files.tsx @@ -5,15 +5,16 @@ // LICENSE file in the root directory of this source tree. import * as React from 'react'; -import { useEffect } from 'react'; import { Assignment } from '../../../model/assignment'; import { Lecture } from '../../../model/lecture'; import { generateAssignment, + getAssignment, pullAssignment, pushAssignment } from '../../../services/assignments.service'; import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded'; +import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'; import { CommitDialog } from '../../util/dialog'; import { Box, @@ -29,7 +30,6 @@ import { Tooltip } from '@mui/material'; import ReplayIcon from '@mui/icons-material/Replay'; -import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'; import TerminalIcon from '@mui/icons-material/Terminal'; import AddIcon from '@mui/icons-material/Add'; import CheckIcon from '@mui/icons-material/Check'; @@ -37,136 +37,107 @@ import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import { FilesList } from '../../util/file-list'; import { GlobalObjects } from '../../../index'; import { Contents } from '@jupyterlab/services'; -import moment from 'moment'; import { openBrowser, openTerminal } from '../overview/util'; import { PageConfig } from '@jupyterlab/coreutils'; import PublishRoundedIcon from '@mui/icons-material/PublishRounded'; import { - IGitLogObject, - getGitLog, getRemoteStatus, lectureBasePath } from '../../../services/file.service'; import { RepoType } from '../../util/repo-type'; import { enqueueSnackbar } from 'notistack'; -import { GitLogModal } from './git-log'; -import { showDialog } from '../../util/dialog-provider'; import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getLecture } from '../../../services/lectures.service'; +import { loadString, storeString } from '../../../services/storage.service'; +import { queryClient } from '../../../widgets/assignmentmanage'; +import { RemoteFileStatus } from '../../../model/remoteFileStatus'; +import { GitLogModal } from './git-log'; -/** - * Props for FilesComponent. - */ export interface IFilesProps { lecture: Lecture; assignment: Assignment; onAssignmentChange: (assignment: Assignment) => void; } -/** - * Renders in a file list the assignment files. - * @param props props of the file component - */ -export const Files = (props: IFilesProps) => { +export const Files = ({ + lecture, + assignment, + onAssignmentChange +}: IFilesProps) => { const navigate = useNavigate(); const reloadPage = () => navigate(0); + const serverRoot = PageConfig.getOption('serverRoot'); - const [assignment, setAssignment] = React.useState(props.assignment); - const [lecture, setLecture] = React.useState(props.lecture); - const [selectedDir, setSelectedDir] = React.useState('source'); - const [gitLogs, setGitLog] = React.useState([] as IGitLogObject[]); - const [assignmentState, setAssignmentState] = React.useState(assignment); + const { data: updatedLecture = lecture } = useQuery({ + queryKey: ['lecture', lecture.id], + queryFn: () => getLecture(lecture.id, true) + }); - const updateGitLog = () => { - getGitLog(lecture, assignment, RepoType.SOURCE, 10).then(logs => - setGitLog(logs) - ); - }; - const updateRemoteStatus = async () => { - const status = await getRemoteStatus( - props.lecture, - props.assignment, - RepoType.SOURCE, - true - ); - setRepoStatus( - status as 'up_to_date' | 'pull_needed' | 'push_needed' | 'divergent' - ); - }; - React.useEffect(() => { - updateGitLog(); - }, [assignmentState]); + const { data: updatedAssignment = assignment } = useQuery({ + queryKey: ['assignment', lecture.id, assignment.id], + queryFn: () => getAssignment(lecture.id, assignment.id, true) + }); + + const { data: selectedDir = 'source', refetch: refetchSelectedDir } = + useQuery({ + queryKey: ['selectedDir'], + queryFn: async () => { + const data = loadString('files-selected-dir'); + if (data) { + return data as 'source' | 'release'; + } else { + return 'source'; + } + } + }); + + const { data: repoStatus, refetch: refetchRepoStatus } = useQuery({ + queryKey: ['repoStatus', lecture.id, assignment.id], + queryFn: async () => { + const response = await getRemoteStatus( + lecture, + assignment, + RepoType.SOURCE, + true + ); + return response.status; + } + }); openBrowser( `${lectureBasePath}${lecture.code}/${selectedDir}/${assignment.id}` ); - const [repoStatus, setRepoStatus] = React.useState( - null as 'up_to_date' | 'pull_needed' | 'push_needed' | 'divergent' - ); - - const [srcChangedTimestamp, setSrcChangeTimestamp] = React.useState( - moment().valueOf() - ); // now - const [generateTimestamp, setGenerateTimestamp] = React.useState(null); - - const serverRoot = PageConfig.getOption('serverRoot'); - - const isCommitOverwrite = () => - repoStatus === 'pull_needed' || repoStatus === 'divergent'; - const isPullOverwrite = () => - repoStatus === 'push_needed' || repoStatus === 'divergent'; - - useEffect(() => { + React.useEffect(() => { const srcPath = `${lectureBasePath}${lecture.code}/source/${assignment.id}`; GlobalObjects.docManager.services.contents.fileChanged.connect( (sender: Contents.IManager, change: Contents.IChangedArgs) => { const { oldValue, newValue } = change; - if (newValue && !newValue.path.includes(srcPath)) { + if ( + (newValue && !newValue.path.includes(srcPath)) || + (oldValue && !oldValue.path.includes(srcPath)) + ) { return; } - - if (oldValue && !oldValue.path.includes(srcPath)) { - return; - } - - if (newValue) { - const modified = moment(newValue.last_modified).valueOf(); - if (srcChangedTimestamp === null || srcChangedTimestamp < modified) { - setSrcChangeTimestamp(modified); - } - } reloadPage(); + refetchRepoStatus(); }, this ); - - getRemoteStatus( - props.lecture, - props.assignment, - RepoType.SOURCE, - true - ).then(status => { - setRepoStatus( - status as 'up_to_date' | 'pull_needed' | 'push_needed' | 'divergent' - ); - }); - }, [props.assignment, props.lecture]); + }, [assignment, lecture]); /** * Switches between source and release directory. * @param dir dir which should be switched to */ const handleSwitchDir = async (dir: 'source' | 'release') => { - if ( - dir === 'release' && - (generateTimestamp === null || generateTimestamp < srcChangedTimestamp) - ) { + if (dir === 'release') { await generateAssignment(lecture.id, assignment) .then(() => { enqueueSnackbar('Generated Student Version Notebooks', { variant: 'success' }); - setGenerateTimestamp(moment().valueOf()); setSelectedDir(dir); }) .catch(error => { @@ -179,135 +150,72 @@ export const Files = (props: IFilesProps) => { ); }); } else { - setSelectedDir(dir); + await setSelectedDir(dir); } }; - /** - * Pushes files to the source und release repo. - * @param commitMessage the commit message - */ - const handlePushAssignment = async (commitMessage: string) => { - showDialog( - 'Push Assignment', - `Do you want to push ${assignment.name}? This updates the state of the assignment on the server with your local state.`, - async () => { - try { - // Note: has to be in this order (release -> source) - await pushAssignment(lecture.id, assignment.id, 'release'); - await pushAssignment( - lecture.id, - assignment.id, - 'source', - commitMessage - ); - - enqueueSnackbar('Successfully Pushed Assignment', { - variant: 'success' - }); - reloadPage(); - } catch (err) { - if (err instanceof Error) { - enqueueSnackbar('Error Pushing Assignment: ' + err.message, { - variant: 'error' - }); - } else { - console.error('Error cannot interpret unknown as error', err); - } - return; - } - } - ); - }; - /** - * Sets the repo status text. - * @param status repo status - */ - const getRemoteStatusText = ( - status: 'up_to_date' | 'pull_needed' | 'push_needed' | 'divergent' - ) => { - if (status === 'up_to_date') { - return 'The local files are up to date with the remote repository.'; - } else if (status === 'pull_needed') { - return 'The remote repository has new changes. Pull now to load them.'; - } else if (status === 'push_needed') { - return 'You have made changes to your local repository which you can push.'; - } else { - return 'The local and remote files are divergent.'; - } + const setSelectedDir = async (dir: 'source' | 'release') => { + storeString('files-selected-dir', dir); + refetchSelectedDir().then(() => { + openBrowser( + `${lectureBasePath}${lecture.code}/${selectedDir}/${assignment.id}` + ); + }); }; - const getStatusChip = ( - status: 'up_to_date' | 'pull_needed' | 'push_needed' | 'divergent' + const handlePushAssignment = async ( + commitMessage: string, + selectedFiles: string[] ) => { - if (status === 'up_to_date') { - return ( - } - /> - ); - } else if (status === 'pull_needed') { - return ( - } - /> + try { + // Note: has to be in this order (release -> source) + await pushAssignment( + lecture.id, + assignment.id, + 'release', + commitMessage, + selectedFiles ); - } else if (status === 'push_needed') { - return ( - } - /> - ); - } else { - return ( - } - /> + await pushAssignment( + lecture.id, + assignment.id, + 'source', + commitMessage, + selectedFiles ); + await queryClient.invalidateQueries({ queryKey: ['assignments'] }); + enqueueSnackbar('Successfully Pushed Assignment', { variant: 'success' }); + refetchRepoStatus(); + } catch (err) { + enqueueSnackbar(`Error Pushing Assignment: ${err}`, { variant: 'error' }); } }; - /** - * Pulls changes from source repository. - */ - const handlePullAssignment = () => { - showDialog( - 'Pull Assignment', - `Do you want to pull ${assignment.name}? This updates your assignment with the state of the server and overwrites all changes.`, - async () => { - try { - await pullAssignment(lecture.id, assignment.id, 'source'); - enqueueSnackbar('Successfully Pulled Assignment', { - variant: 'success' - }); - reloadPage(); - } catch (err) { - if (err instanceof Error) { - enqueueSnackbar('Error Pulling Assignment: ' + err.message, { - variant: 'error' - }); - } else { - console.error('Error cannot interpret unknown as error', err); - } - } - } - ); + const handlePullAssignment = async () => { + try { + await pullAssignment(lecture.id, assignment.id, 'source'); + enqueueSnackbar('Successfully Pulled Assignment', { variant: 'success' }); + await refetchRepoStatus(); + } catch (err) { + enqueueSnackbar(`Error Pulling Assignment: ${err}`, { variant: 'error' }); + } + }; + + const getRemoteStatusText = (status: RemoteFileStatus.StatusEnum) => { + switch (status) { + case RemoteFileStatus.StatusEnum.UpToDate: + return 'The local files are up to date with the remote repository.'; + case RemoteFileStatus.StatusEnum.PullNeeded: + return 'The remote repository has new changes. Pull now to update your local files.'; + case RemoteFileStatus.StatusEnum.PushNeeded: + return 'You have made changes to your local repository which you can push.'; + case RemoteFileStatus.StatusEnum.Divergent: + return 'The local and remote files are divergent.'; + case RemoteFileStatus.StatusEnum.NoRemoteRepo: + return 'There is no remote repository yet. Push your assignment to create it.'; + default: + return ''; + } }; const newUntitled = async () => { @@ -315,13 +223,70 @@ export const Files = (props: IFilesProps) => { type: 'notebook', path: `${lectureBasePath}${lecture.code}/source/${assignment.id}` }); - await updateRemoteStatus(); - await GlobalObjects.docManager.openOrReveal(res.path); + GlobalObjects.docManager.openOrReveal(res.path); + }; + + const getStatusChip = (status: RemoteFileStatus.StatusEnum) => { + // Define the statusMap with allowed `Chip` color values + const statusMap: Record< + RemoteFileStatus.StatusEnum, + { + label: string; + color: + | 'default' + | 'primary' + | 'secondary' + | 'error' + | 'warning' + | 'info' + | 'success'; + icon: JSX.Element; + } + > = { + UP_TO_DATE: { + label: 'Up To Date', + color: 'success', + icon: + }, + PULL_NEEDED: { + label: 'Pull Needed', + color: 'warning', + icon: + }, + PUSH_NEEDED: { + label: 'Push Needed', + color: 'warning', + icon: + }, + DIVERGENT: { + label: 'Divergent', + color: 'error', + icon: + }, + NO_REMOTE_REPO: { + label: 'No Remote Repository', + color: 'primary', + icon: + } + }; + + // Fallback if the status is not in the statusMap (it should be) + const { label, color, icon } = statusMap[status] || {}; + + // Return the Chip component with appropriate props or null if status is invalid + return label ? ( + + ) : null; }; return ( { titleTypographyProps={{ display: 'inline' }} action={ - reloadPage()}> + } subheader={ - repoStatus !== null && ( + repoStatus && ( {getStatusChip(repoStatus)} @@ -360,45 +325,43 @@ export const Files = (props: IFilesProps) => { - handlePushAssignment(msg)}> - + + Push - + handlePullAssignment()} variant="outlined" size="small" + onClick={handlePullAssignment} + sx={{ mt: -1 }} > Pull - + { onClick={newUntitled} > - Add new + Create Notebook - + { }; export const GitLogModal = (props: IGitLogProps) => { - const [gitLogs, setGitLogs] = React.useState(props.gitLogs); - React.useEffect(() => { - setGitLogs(props.gitLogs); - }, [props.gitLogs]); + const { data: gitLogs = [] as IGitLogObject[], refetch: refetchGitLogs } = + useQuery({ + queryKey: ['gitLogs', props.lecture, props.assignment], + queryFn: () => + getGitLog(props.lecture, props.assignment, RepoType.SOURCE, 10) + }); const [open, setOpen] = React.useState(false); - const handleOpen = () => { + + const handleOpen = async () => { setOpen(true); + await refetchGitLogs(); }; + const handleClose = () => { setOpen(false); }; diff --git a/src/components/coursemanage/grading/create-submission.tsx b/src/components/coursemanage/grading/create-submission.tsx index 9475ad0..cce4235 100644 --- a/src/components/coursemanage/grading/create-submission.tsx +++ b/src/components/coursemanage/grading/create-submission.tsx @@ -3,10 +3,10 @@ import { AlertTitle, Box, Button, - IconButton, + Card, + LinearProgress, Stack, TextField, - Tooltip, Typography } from '@mui/material'; import * as React from 'react'; @@ -14,61 +14,82 @@ import { Lecture } from '../../../model/lecture'; import { Assignment } from '../../../model/assignment'; import { Submission } from '../../../model/submission'; import { FilesList } from '../../util/file-list'; -import { - lectureBasePath, - makeDir, - makeDirs -} from '../../../services/file.service'; -import { Link, useOutletContext, useRouteLoaderData } from 'react-router-dom'; +import { lectureBasePath, makeDirs } from '../../../services/file.service'; +import { Link, useOutletContext } from 'react-router-dom'; import { showDialog } from '../../util/dialog-provider'; import Autocomplete from '@mui/material/Autocomplete'; import moment from 'moment'; import { Contents } from '@jupyterlab/services'; import { GlobalObjects } from '../../../index'; import { openBrowser } from '../overview/util'; -import ReplayIcon from '@mui/icons-material/Replay'; -import { - createSubmissionFiles, - pushSubmissionFiles -} from '../../../services/submissions.service'; +import { createSubmissionFiles } from '../../../services/submissions.service'; import { enqueueSnackbar } from 'notistack'; import { GraderLoadingButton } from '../../util/loading-button'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { getLecture, getUsers } from '../../../services/lectures.service'; +import { extractIdsFromBreadcrumbs } from '../../util/breadcrumbs'; export const CreateSubmission = () => { - const { assignment, rows, setRows } = useOutletContext() as { - lecture: Lecture; + const { assignment } = useOutletContext() as { assignment: Assignment; - rows: Submission[]; - setRows: React.Dispatch>; - manualGradeSubmission: Submission; - setManualGradeSubmission: React.Dispatch>; - }; - const { lecture, assignments, users } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: Assignment[]; - users: { instructors: string[]; tutors: string[]; students: string[] }; }; - const [path, setPath] = React.useState(null); - const submissionsLink = `/lecture/${lecture.id}/assignment/${assignment.id}/submissions`; - const [userDir, setUserDir] = React.useState(null); + const { lectureId } = extractIdsFromBreadcrumbs(); + + const { data: lecture, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId, true), + enabled: !!lectureId + }); + const { data: students = [], isLoading: isLoadingStudents } = useQuery< + string[] + >({ + queryKey: ['students', lectureId], + queryFn: async () => { + const users = await getUsers(lectureId); + return users['students']; + }, + enabled: !!lectureId + }); + + const { data: path, refetch: reloadPath } = useQuery({ + queryKey: ['path', lectureBasePath, lecture.code, assignment.id], + queryFn: () => + makeDirs(`${lectureBasePath}${lecture.code}`, [ + 'create', + `${assignment.id}`, + userDir + ]) + }); + + const [userDir, setUserDir] = React.useState(null); + const submissionsLink = `/lecture/${lecture.id}/assignment/${assignment.id}/submissions`; const [srcChangedTimestamp, setSrcChangeTimestamp] = React.useState( moment().valueOf() ); // now + const createSubmissionMutation = useMutation({ + mutationFn: async () => { + return createSubmissionFiles(lecture, assignment, userDir); + }, + onError: (error: any) => { + enqueueSnackbar('Error: ' + error.message, { variant: 'error' }); + }, + onSuccess: () => { + enqueueSnackbar(`Successfully Created Submission for user: ${userDir}`, { + variant: 'success' + }); + } + }); + React.useEffect(() => { - makeDirs(`${lectureBasePath}${lecture.code}`, [ - 'create', - `${assignment.id}`, - userDir - ]).then(p => { - setPath(p); - openBrowser(p); + if (path) { + openBrowser(path); GlobalObjects.docManager.services.contents.fileChanged.connect( (sender: Contents.IManager, change: Contents.IChangedArgs) => { - const { oldValue, newValue } = change; - if (!newValue.path.includes(p)) { + const { newValue } = change; + if (!newValue.path.includes(path)) { return; } @@ -79,32 +100,21 @@ export const CreateSubmission = () => { }, this ); - }); - }); + } + }, [path]); - const createSubmission = async () => { - // TODO: call pus submission and the rest is handled in the labestension server - await createSubmissionFiles(lecture, assignment, userDir).then( - response => { - enqueueSnackbar( - `Successfully Created Submission for user: ${userDir}`, - { - variant: 'success' - } - ); - }, - err => { - enqueueSnackbar(err.message, { - variant: 'error' - }); - } + if (isLoadingLecture || isLoadingStudents) { + return ( + + + + + ); - }; + } - const [reloadFilesToggle, setReloadFiles] = React.useState(false); - - const reloadFiles = () => { - setReloadFiles(!reloadFilesToggle); + const createSubmission = async () => { + createSubmissionMutation.mutate(); }; return ( @@ -112,27 +122,26 @@ export const CreateSubmission = () => { Info - If you want to create a submission for a student manually, make sure + If you want to manually create a submission for a student, make sure to follow these steps: - 1. By selecting a student for whom you want to create - submission, directory 'create/{assignment.id}/student_id' is - automatically opened in File Browser on your left-hand side. + 1. Select the student for whom you want to create a submission. - 2. Upload the desired files here. They will automatically - appear in the Submission Files below. + 2. When you select the student for whom you want to create a + submission, the directory 'create/{assignment.id}/student_id' will + will automatically open in the File Browser on the left. - 3. Choose the student for whom you want to create the - submission. + 3. Upload the desired files here. They will appear automatically in the Submission Files list below. 4. Push the submission. Select a student { setUserDir(newUserDir); + reloadPath(); }} sx={{ m: 2 }} renderInput={params => ( @@ -141,27 +150,18 @@ export const CreateSubmission = () => { label="Select Student" inputProps={{ ...params.inputProps - // autoComplete: 'new-password', }} /> )} /> - - Submission Files - - reloadFiles()}> - - - - - - + Submission Files + { const navigate = useNavigate(); - - const { - lecture, - assignment, - rows, - setRows, - manualGradeSubmission, - setManualGradeSubmission - } = useOutletContext() as { + + const { lecture, assignment, manualGradeSubmission } = useOutletContext() as { lecture: Lecture; assignment: Assignment; - rows: Submission[]; - setRows: React.Dispatch>; manualGradeSubmission: Submission; - setManualGradeSubmission: React.Dispatch>; }; + const path = `${lectureBasePath}${lecture.code}/edit/${assignment.id}/${manualGradeSubmission.id}`; - const [submission, setSubmission] = React.useState(manualGradeSubmission); - const [showLogs, setShowLogs] = React.useState(false); - const [logs, setLogs] = React.useState(undefined); + const { data: submission, refetch: refetchSubmission } = useQuery({ + queryKey: [ + 'submission', + lecture.id, + assignment.id, + manualGradeSubmission.id + ], + queryFn: () => + getSubmission(lecture.id, assignment.id, manualGradeSubmission.id, true) + }); - - const reload = () => { - getSubmission(lecture.id, assignment.id, submission.id, true).then(s => - setSubmission(s) - ); + const { data: logs, refetch: refetchLogs } = useQuery({ + queryKey: ['logs', lecture.id, assignment.id, manualGradeSubmission.id], + queryFn: () => getLogs(lecture.id, assignment.id, manualGradeSubmission.id) + }); + + const { data: submissionFiles, refetch: refetchSubmissionFiles } = useQuery({ + queryKey: ['submissionFiles'], + queryFn: () => getFiles(path) + }); + + const [showLogs, setShowLogs] = React.useState(false); + + const reloadSubmission = async () => { + await refetchSubmission(); }; - - const openLogs = (event: React.MouseEvent, submissionId: number) => { - getLogs(lecture.id, assignment.id, submissionId).then( - logs => { - setLogs(logs); - setShowLogs(true); - }, - error => { - enqueueSnackbar('No logs for submission', { - variant: 'error' - }); - } - ); - event.stopPropagation(); + const openLogs = async () => { + setShowLogs(true); + await refetchLogs(); }; const pushEditedFiles = async () => { @@ -98,7 +94,7 @@ export const EditSubmission = () => { enqueueSnackbar('Successfully Pulled Submission', { variant: 'success' }); - reload(); + refetchSubmissionFiles(); }, err => { enqueueSnackbar(err.message, { @@ -118,7 +114,7 @@ export const EditSubmission = () => { enqueueSnackbar('Successfully Created Edit Repository', { variant: 'success' }); - setSubmission(response); + reloadSubmission(); }, err => { enqueueSnackbar(err.message, { @@ -153,7 +149,7 @@ export const EditSubmission = () => { color="text.primary" sx={{ display: 'inline-block', fontSize: 16, height: 35 }} > - {submission.username} + {submission?.username} { sx={{ mr: 2 }} variant="outlined" size="small" - onClick={event => openLogs(event, manualGradeSubmission.id)} + onClick={openLogs} > Show Logs - + { await setEditRepository(); }} + onClick={async () => { + await setEditRepository(); + }} > - {submission.edited ? 'Reset ' : 'Create '} + {submission?.edited ? 'Reset ' : 'Create '} Edit Repository - + { await handlePullEditedSubmission(); }}> + disabled={!submission?.edited} + onClick={async () => { + await handlePullEditedSubmission(); + }} + > Pull Submission - - { - showDialog( - 'Edit Submission', - 'Do you want to push your submission changes?', - async () => { - await pushEditedFiles(); - } - ); - }} - > - Push Edited Submission - + + { + showDialog( + 'Edit Submission', + 'Do you want to push your submission changes?', + async () => { + await pushEditedFiles(); + } + ); + }} + > + Push Edited Submission + - navigate(-1)}> + navigate(-1)}> Back @@ -234,7 +240,7 @@ export const EditSubmission = () => { id="alert-dialog-description" sx={{ fontSize: 10, fontFamily: "'Roboto Mono', monospace" }} > - {logs} + {logs || 'No logs available'} diff --git a/src/components/coursemanage/grading/grading.tsx b/src/components/coursemanage/grading/grading.tsx index fc92a46..967d49d 100644 --- a/src/components/coursemanage/grading/grading.tsx +++ b/src/components/coursemanage/grading/grading.tsx @@ -3,23 +3,16 @@ import Box from '@mui/material/Box'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; import TableRow from '@mui/material/TableRow'; import TableSortLabel from '@mui/material/TableSortLabel'; import Typography from '@mui/material/Typography'; -import Paper from '@mui/material/Paper'; import Checkbox from '@mui/material/Checkbox'; import { visuallyHidden } from '@mui/utils'; import { Lecture } from '../../../model/lecture'; import { Assignment } from '../../../model/assignment'; -import { - Outlet, - useNavigate, - useOutletContext, - useRouteLoaderData -} from 'react-router-dom'; +import { Outlet, useNavigate, useOutletContext } from 'react-router-dom'; import { Submission } from '../../../model/submission'; import { utcToLocalFormat } from '../../../services/datetime.service'; import { @@ -30,9 +23,7 @@ import { DialogContent, DialogTitle, IconButton, - Stack, - Toolbar, - Tooltip + Stack } from '@mui/material'; import { SectionTitle } from '../../util/section-title'; import { enqueueSnackbar } from 'notistack'; @@ -43,13 +34,16 @@ import { import { EnhancedTableToolbar } from './table-toolbar'; import EditNoteOutlinedIcon from '@mui/icons-material/EditNoteOutlined'; import { green } from '@mui/material/colors'; -import AddIcon from '@mui/icons-material/Add'; import { loadNumber, loadString, storeNumber, storeString } from '../../../services/storage.service'; +import { getAssignment } from '../../../services/assignments.service'; +import { useQuery } from '@tanstack/react-query'; +import { getLecture } from '../../../services/lectures.service'; +import { extractIdsFromBreadcrumbs } from '../../util/breadcrumbs'; /** * Calculates chip color based on submission status. @@ -586,23 +580,31 @@ export default function GradingTable() { } export const GradingComponent = () => { - const { lecture, assignments, users } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: Assignment[]; - users: { instructors: string[]; tutors: string[]; students: string[] }; - }; - const { assignment, allSubmissions, latestSubmissions } = useRouteLoaderData( - 'assignment' - ) as { - assignment: Assignment; - allSubmissions: Submission[]; - latestSubmissions: Submission[]; - }; + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); + const [rows, setRows] = React.useState([]); + const [manualGradeSubmission, setManualGradeSubmission] = React.useState< + Submission | undefined + >(undefined); + + const { data: lectureData, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId), + enabled: !!lectureId + }); - const [rows, setRows] = React.useState([] as Submission[]); - const [manualGradeSubmission, setManualGradeSubmission] = React.useState( - undefined as Submission - ); + const { data: assignmentData, isLoading: isLoadingAssignment } = + useQuery({ + queryKey: ['assignment', assignmentId], + queryFn: () => getAssignment(lectureId, assignmentId), + enabled: !!lectureId && !!assignmentId + }); + + if (isLoadingLecture || isLoadingAssignment) { + return Loading...; + } + + const lecture = lectureData; + const assignment = assignmentData; return ( diff --git a/src/components/coursemanage/grading/manual-grading.tsx b/src/components/coursemanage/grading/manual-grading.tsx index 2b19c58..637ab12 100644 --- a/src/components/coursemanage/grading/manual-grading.tsx +++ b/src/components/coursemanage/grading/manual-grading.tsx @@ -1,11 +1,8 @@ -import { SectionTitle } from '../../util/section-title'; import { Alert, AlertTitle, Box, Button, - Checkbox, - FormControlLabel, IconButton, Modal, Stack, @@ -32,7 +29,7 @@ import { FilesList } from '../../util/file-list'; import ReplayIcon from '@mui/icons-material/Replay'; import { enqueueSnackbar } from 'notistack'; import { openBrowser } from '../overview/util'; -import { lectureBasePath } from '../../../services/file.service'; +import { getFiles, lectureBasePath } from '../../../services/file.service'; import { Link, useOutletContext } from 'react-router-dom'; import { utcToLocalFormat } from '../../../services/datetime.service'; import Toolbar from '@mui/material/Toolbar'; @@ -46,6 +43,8 @@ import { import { showDialog } from '../../util/dialog-provider'; import InfoIcon from '@mui/icons-material/Info'; import { GraderLoadingButton } from '../../util/loading-button'; +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../../widgets/assignmentmanage'; const style = { position: 'absolute' as const, @@ -78,23 +77,24 @@ const InfoModal = () => { Manual Grading Information Info - If you want to manually grade an assignment, make sure to follow - these steps: + If you want to manually grade an assignment, please follow these + steps: - 1. In order to grade a submission manually, the submission - must first be auto-graded. This sets meta data for manual grading. - However, we're actively working towards enabling direct manual - grading without the necessity of auto-grading in the future. + 1. To grade a submission manually, it must first be + auto-graded. This step sets the necessary metadata for manual + grading. We are working on enabling direct manual grading without + auto-grading in the future. - 2. Once the meta data was set for submission, you can pull - the submission. + 2. Once the metadata has been set for the submission, you can + pull the submission. - 3. From file list access submission files and grade them - manually. + 3. Access the submission files from the file list and grade + them manually. - 4. After you've completed the grading of the submission, - click "FINISH MANUAL GRADING." This action will save the grading and - determine the points that the student receives for their submission. + 4. After you've completed the grading of the submission and + saved revised notebook, click button "FINISH MANUAL GRADING". This + action will save the grading and determine the points that the + student receives for their submission. Close @@ -119,55 +119,63 @@ export const ManualGrading = () => { manualGradeSubmission: Submission; setManualGradeSubmission: React.Dispatch>; }; - const [submission, setSubmission] = React.useState(manualGradeSubmission); - const mPath = `${lectureBasePath}${lecture.code}/manualgrade/${assignment.id}/${submission.id}`; - const rowIdx = rows.findIndex(s => s.id === submission.id); + + const rowIdx = rows.findIndex(s => s.id === manualGradeSubmission.id); const submissionsLink = `/lecture/${lecture.id}/assignment/${assignment.id}/submissions`; + const { + data: submission = manualGradeSubmission, + refetch: refetchSubmission + } = useQuery({ + queryKey: [ + 'submission', + lecture.id, + assignment.id, + manualGradeSubmission.id + ], + queryFn: () => + getSubmission(lecture.id, assignment.id, manualGradeSubmission.id, true) + }); + const [submissionScaling, setSubmissionScaling] = React.useState( submission.score_scaling ); - const [manualPath, setManualPath] = React.useState(mPath); - const [gradeBook, setGradeBook] = React.useState(null); React.useEffect(() => { - reloadProperties(submission); - }, []); + refetchSubmission().then(response => { + setSubmissionScaling(response.data.score_scaling); + refetchGradeBook().then(async () => { + const manualPath = `${lectureBasePath}${lecture.code}/manualgrade/${assignment.id}/${manualGradeSubmission.id}`; + const files = await getFiles(manualPath); + if (files.length === 0) { + openBrowser( + `${lectureBasePath}${lecture.code}/source/${assignment.id}` + ); + } else { + openBrowser(manualPath); + } + }); + }); + }, [manualGradeSubmission.id]); - const reloadManualPath = (submission) => { - const mPath = `${lectureBasePath}${lecture.code}/manualgrade/${assignment.id}/${submission.id}`; - setManualPath(mPath); - }; - - const reloadProperties = (submission) => { - getProperties(lecture.id, assignment.id, submission.id, true).then( - properties => { - const gradeBook = new GradeBook(properties); - setGradeBook(gradeBook); - } - ); - }; - - const reloadSubmission = (submission) => { - getSubmission(lecture.id, assignment.id, submission.id, true).then(s => - setSubmission(s) - ); - }; - - const reload = (submission) => { - reloadSubmission(submission); - reloadProperties(submission); - reloadManualPath(submission); - }; + const { data: gradeBook, refetch: refetchGradeBook } = useQuery({ + queryKey: ['gradeBook', submission.id], + queryFn: () => + getProperties(lecture.id, assignment.id, submission.id, true).then( + properties => new GradeBook(properties) + ), + enabled: !!submission + }); const handleAutogradeSubmission = async () => { await autogradeSubmissionsDialog(async () => { try { - await autogradeSubmission(lecture, assignment, submission); + await autogradeSubmission(lecture, assignment, submission).then(() => { + refetchSubmission(); + }); enqueueSnackbar('Autograding submission!', { variant: 'success' }); - reload(submission); } catch (err) { console.error(err); enqueueSnackbar('Error Autograding Submission', { @@ -180,11 +188,15 @@ export const ManualGrading = () => { const handleGenerateFeedback = async () => { await generateFeedbackDialog(async () => { try { - await generateFeedback(lecture, assignment, submission); + await generateFeedback(lecture, assignment, submission).then(() => { + refetchSubmission().then(() => refetchGradeBook()); + }); enqueueSnackbar('Generating feedback for submission!', { variant: 'success' }); - reload(submission); + await queryClient.invalidateQueries({ + queryKey: ['submissionsAssignmentStudent'] + }); } catch (err) { console.error(err); enqueueSnackbar('Error Generating Feedback', { @@ -204,19 +216,20 @@ export const ManualGrading = () => { const finishGrading = () => { submission.manual_status = 'manually_graded'; - if(submission.feedback_status === 'generated') submission.feedback_status = 'feedback_outdated'; + if (submission.feedback_status === 'generated') { + submission.feedback_status = 'feedback_outdated'; + } updateSubmission(lecture.id, assignment.id, submission.id, submission).then( response => { + refetchSubmission().then(() => refetchGradeBook()); enqueueSnackbar('Successfully Graded Submission', { variant: 'success' }); - reload(submission); }, err => { enqueueSnackbar(err.message, { variant: 'error' }); - reload(submission); } ); }; @@ -224,11 +237,13 @@ export const ManualGrading = () => { const handlePullSubmission = async () => { createManualFeedback(lecture.id, assignment.id, submission.id).then( response => { - openBrowser(manualPath); + openBrowser( + `${lectureBasePath}${lecture.code}/manualgrade/${assignment.id}/${submission.id}` + ); + refetchGradeBook(); enqueueSnackbar('Successfully Pulled Submission', { variant: 'success' }); - reload(submission); }, err => { enqueueSnackbar(err.message, { @@ -238,14 +253,13 @@ export const ManualGrading = () => { ); }; - const handleNavigation = (direction) => { - const currentIndex = rows.findIndex(s => s.id === manualGradeSubmission.id); + const handleNavigation = direction => { + const currentIndex = rows.findIndex(s => s.id === submission.id); const newIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1; if (newIndex >= 0 && newIndex < rows.length) { const newSubmission = rows[newIndex]; setManualGradeSubmission(newSubmission); - reload(newSubmission); } }; @@ -392,11 +406,20 @@ export const ManualGrading = () => { - + - reload(submission)}> + refetchSubmission().then(() => refetchGradeBook())} + > @@ -419,12 +442,12 @@ export const ManualGrading = () => { disabled={submission.auto_status !== 'automatically_graded'} color="primary" variant="outlined" - onClick={async () => { await handlePullSubmission(); }} + onClick={handlePullSubmission} sx={{ whiteSpace: 'nowrap', minWidth: 'auto' }} > Pull Submission - + { > Finish Manual Grading - - Edit Submission - + + + Edit Submission + + {submission.auto_status === 'automatically_graded' ? ( { Back - handleNavigation('previous')} > - - handleNavigation('next')} - > - - + + handleNavigation('next')} + > + + diff --git a/src/components/coursemanage/grading/table-toolbar.tsx b/src/components/coursemanage/grading/table-toolbar.tsx index 6fe0936..7f67e65 100644 --- a/src/components/coursemanage/grading/table-toolbar.tsx +++ b/src/components/coursemanage/grading/table-toolbar.tsx @@ -34,6 +34,7 @@ import { Link } from 'react-router-dom'; import { openBrowser } from '../overview/util'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; +import { queryClient } from '../../../widgets/assignmentmanage'; export const autogradeSubmissionsDialog = async handleAgree => { showDialog( @@ -115,14 +116,14 @@ export function EnhancedTableToolbar(props: EnhancedTableToolbarProps) { .then(response => { enqueueSnackbar( 'Successfully matched ' + - response.syncable_users + - ' submissions with learning platform', + response.syncable_users + + ' submissions with learning platform', { variant: 'success' } ); enqueueSnackbar( 'Successfully synced latest submissions with feedback of ' + - response.synced_user + - ' users', + response.synced_user + + ' users', { variant: 'success' } ); }) @@ -186,6 +187,9 @@ export function EnhancedTableToolbar(props: EnhancedTableToolbarProps) { enqueueSnackbar(`Generating feedback for ${numSelected} submissions!`, { variant: 'success' }); + await queryClient.invalidateQueries({ + queryKey: ['submissionsAssignmentStudent'] + }); } catch (err) { console.error(err); enqueueSnackbar('Error Generating Feedback', { @@ -300,7 +304,8 @@ export function EnhancedTableToolbar(props: EnhancedTableToolbarProps) { diff --git a/src/components/coursemanage/lecture.tsx b/src/components/coursemanage/lecture.tsx index 881aa2a..e1b2b80 100644 --- a/src/components/coursemanage/lecture.tsx +++ b/src/components/coursemanage/lecture.tsx @@ -18,27 +18,30 @@ import { import * as React from 'react'; import { Assignment } from '../../model/assignment'; import { Lecture } from '../../model/lecture'; -import { deleteAssignment } from '../../services/assignments.service'; -import { CreateDialog, EditLectureDialog, IEditLectureProps } from '../util/dialog'; -import { updateLecture } from '../../services/lectures.service'; +import { + deleteAssignment, + getAllAssignments +} from '../../services/assignments.service'; +import { CreateDialog, EditLectureDialog } from '../util/dialog'; +import { getLecture, updateLecture } from '../../services/lectures.service'; import { red, grey } from '@mui/material/colors'; import { enqueueSnackbar } from 'notistack'; -import { - useNavigate, - useNavigation, - useRouteLoaderData -} from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { ButtonTr, GraderTable } from '../util/table'; import { DeadlineComponent } from '../util/deadline'; import CloseIcon from '@mui/icons-material/Close'; import SearchIcon from '@mui/icons-material/Search'; import { showDialog } from '../util/dialog-provider'; import { updateMenus } from '../../menu'; +import { extractIdsFromBreadcrumbs } from '../util/breadcrumbs'; +import { useQuery } from '@tanstack/react-query'; +import { AssignmentDetail } from '../../model/assignmentDetail'; +import { queryClient } from '../../widgets/assignmentmanage'; interface IAssignmentTableProps { lecture: Lecture; rows: Assignment[]; - setAssignments: React.Dispatch>; + refreshAssignments: any; } const AssignmentTable = (props: IAssignmentTableProps) => { @@ -116,9 +119,7 @@ const AssignmentTable = (props: IAssignmentTableProps) => { variant: 'success' } ); - props.setAssignments( - props.rows.filter(a => a.id !== row.id) - ); + props.refreshAssignments(); } catch (error: any) { enqueueSnackbar(error.message, { variant: 'error' @@ -151,38 +152,44 @@ const AssignmentTable = (props: IAssignmentTableProps) => { }; export const LectureComponent = () => { - const { lecture, assignments } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: Assignment[]; - users: { instructors: string[]; tutors: string[]; students: string[] }; - }; - const navigation = useNavigation(); - - const [lectureState, setLecture] = React.useState(lecture); - const [assignmentsState, setAssignments] = React.useState(assignments); const [isEditDialogOpen, setEditDialogOpen] = React.useState(false); + const { lectureId } = extractIdsFromBreadcrumbs(); - const handleOpenEditDialog = () => { - setEditDialogOpen(true); - }; + const { data: lecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId, true), + enabled: true + }); - - const handleUpdateLecture = (updatedLecture) => { + const { + data: assignments = [], + isPending: isPendingAssignments, + refetch: refreshAssignments + } = useQuery({ + queryKey: ['assignments', lectureId], + queryFn: () => getAllAssignments(lectureId), + enabled: true + }); + + const handleUpdateLecture = updatedLecture => { updateLecture(updatedLecture).then( - async (response) => { + async response => { await updateMenus(true); - setLecture(response); + // Invalidate query key "lectures" and "completedLectures", so that we trigger refetch on lectures table and correct lecture name is shown in the table! + await queryClient.invalidateQueries({ queryKey: ['lectures'] }); + await queryClient.invalidateQueries({ + queryKey: ['completedLectures'] + }); }, - (error) => { + error => { enqueueSnackbar(error.message, { - variant: 'error', + variant: 'error' }); } ); }; - - if (navigation.state === 'loading') { + if (isPendingAssignments) { return ( @@ -195,8 +202,8 @@ export const LectureComponent = () => { return ( - {lectureState.name} - {lectureState.complete ? ( + {lecture.name} + {lecture.complete ? ( { alignItems="center" sx={{ mt: 2, mb: 1 }} > - + {lecture.code === lecture.name ? ( - The name of the lecture is identical to the lecture code. You should give it a meaningful title that accurately reflects its content.{' '} - + The name of the lecture is identical to the lecture code. You + should give it a meaningful title that accurately reflects its + content.{' '} + setEditDialogOpen(true)} + > Rename Lecture. @@ -232,16 +244,13 @@ export const LectureComponent = () => { { - setAssignments((oldAssignments: Assignment[]) => [ - ...oldAssignments, - assigment - ]); + lecture={lecture} + handleSubmit={async () => { + await refreshAssignments(); }} /> setEditDialogOpen(false)} @@ -249,15 +258,14 @@ export const LectureComponent = () => { - Assignments ); -}; +}; \ No newline at end of file diff --git a/src/components/coursemanage/overview/assignment-status.tsx b/src/components/coursemanage/overview/assignment-status.tsx index 79541b6..ade326d 100644 --- a/src/components/coursemanage/overview/assignment-status.tsx +++ b/src/components/coursemanage/overview/assignment-status.tsx @@ -22,6 +22,7 @@ import UndoIcon from '@mui/icons-material/Undo'; import TerminalIcon from '@mui/icons-material/Terminal'; import { ReleaseDialog } from '../../util/dialog'; import { + getAssignment, pushAssignment, updateAssignment } from '../../../services/assignments.service'; @@ -29,6 +30,8 @@ import { Lecture } from '../../../model/lecture'; import { enqueueSnackbar } from 'notistack'; import { DeadlineComponent } from '../../util/deadline'; import { showDialog } from '../../util/dialog-provider'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../../widgets/assignmentmanage'; /** * Props for AssignmentStatusComponent. @@ -60,7 +63,28 @@ const getActiveStep = (status: Assignment.StatusEnum) => { * @param props props of assignment status */ export const AssignmentStatus = (props: IAssignmentStatusProps) => { - const [assignment, setAssignment] = React.useState(props.assignment); + const { data: assignment = props.assignment, refetch: refetchAssignment } = + useQuery({ + queryKey: ['assignment'], + queryFn: () => getAssignment(props.lecture.id, props.assignment.id, true) + }); + + const updateStatusMutation = useMutation({ + mutationFn: async (status: 'pushed' | 'released' | 'complete') => { + const updatedAssignment = { ...props.assignment, status }; + return updateAssignment(props.lecture.id, updatedAssignment); + }, + onError: (error: any) => { + enqueueSnackbar('Error: ' + error.message, { variant: 'error' }); + }, + onSuccess: (data: Assignment) => { + props.onAssignmentChange(data); + enqueueSnackbar('Successfully updated assignment', { + variant: 'success' + }); + } + }); + /** * Updates assignment status. * @param status assignment status @@ -73,18 +97,12 @@ export const AssignmentStatus = (props: IAssignmentStatusProps) => { error: string ) => { try { - let a = assignment; - a.status = status; - a = await updateAssignment(props.lecture.id, a); - setAssignment(a); - props.onAssignmentChange(a); - enqueueSnackbar(success, { - variant: 'success' - }); + await updateStatusMutation.mutateAsync(status); + await refetchAssignment(); + await queryClient.invalidateQueries({ queryKey: ['assignments'] }); + enqueueSnackbar(success, { variant: 'success' }); } catch (err) { - enqueueSnackbar(error, { - variant: 'error' - }); + enqueueSnackbar(error, { variant: 'error' }); } }; /** @@ -131,14 +149,14 @@ export const AssignmentStatus = (props: IAssignmentStatusProps) => { description: ( - The assignment has been created and files can now be added to be - pushed. You can commit and pull changes from the remote file - repository through the file view or can work directly with the - underlying git repositories by opening the assignment in the - terminal ( - ). After you are done working on the files you can release the - assignment, which makes a final commit with the current state of the - assignment. + The assignment has been created, and files can now be added and + pushed. You can commit and pull changes from the remote repository + through the file view, or work directly with the underlying Git + repositories by opening the assignment in the terminal ( + + ). Once you’re done working on the files, you can release the + assignment, which will make a final commit with the current state of + the assignment. { description: ( - The assignment has been released to students and it is not advised - to push further changes to the repository. If the assignment is over - you can mark it as complete in the edit menu or right here. Undoing - the release of the assignment will hide the assignment from students - again but their local files are unaffected by this action. When - re-releasing the assignment after significant changes a new commit - will be made and you will have to instruct users to reset their - progress thus far or work with separate versions later on. + The assignment has been released to students, and further changes to + the repository are not advised. When the assignment period is over, + you can mark it as complete in the edit menu or directly here. + Undoing the release will hide the assignment from students, but + their local files will remain unaffected. If you re-release the + assignment after making significant changes, a new commit will be + made, and you may need to instruct users to reset their progress or + work with separate versions. { description: ( - The assignment has been completed and is not visible to students - anymore but all their progress will be saved. When re-activating the - assignment it will again show up in the assignment view and new - submissions can be made given the deadline is set accordingly. + The assignment has been completed and is no longer visible to + students, but all their progress has been saved. If you re-activate + the assignment, it will reappear in the assignment view, allowing + new submissions as long as the deadline is adjusted accordingly. { async () => { await updateAssignmentStatus( 'complete', - 'Successfully Updated Assignment', + 'Successfully Completed Assignment', 'Error Updating Assignment' ); } diff --git a/src/components/coursemanage/overview/overview-card.tsx b/src/components/coursemanage/overview/overview-card.tsx index 2ee9b8d..491ff59 100644 --- a/src/components/coursemanage/overview/overview-card.tsx +++ b/src/components/coursemanage/overview/overview-card.tsx @@ -8,7 +8,6 @@ import { Divider, Grid, Paper, Typography, createTheme } from '@mui/material'; import * as React from 'react'; import { Assignment } from '../../../model/assignment'; import { Lecture } from '../../../model/lecture'; -import { Submission } from '../../../model/submission'; import GroupIcon from '@mui/icons-material/Group'; import ChecklistIcon from '@mui/icons-material/Checklist'; import GradeIcon from '@mui/icons-material/Grade'; @@ -17,9 +16,8 @@ import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; export interface IOverviewCardProps { lecture: Lecture; assignment: Assignment; - allSubmissions: Submission[]; - latestSubmissions: Submission[]; - users: { students: string[]; tutors: string[]; instructors: string[] }; + latestSubmissions: number; + students: number; } export const OverviewCard = (props: IOverviewCardProps) => { @@ -54,7 +52,7 @@ export const OverviewCard = (props: IOverviewCardProps) => { {'Overall number of students in this lecture: ' + - props.users?.students.length} + props.students} @@ -73,7 +71,7 @@ export const OverviewCard = (props: IOverviewCardProps) => { - {'Total number of submissions: ' + props.allSubmissions.length} + {'Number students that have submitted: ' + props.latestSubmissions} diff --git a/src/components/coursemanage/overview/overview.tsx b/src/components/coursemanage/overview/overview.tsx index 363696a..8f0c5af 100644 --- a/src/components/coursemanage/overview/overview.tsx +++ b/src/components/coursemanage/overview/overview.tsx @@ -10,50 +10,87 @@ import { Assignment } from '../../../model/assignment'; import { Lecture } from '../../../model/lecture'; import { SectionTitle } from '../../util/section-title'; import { OverviewCard } from './overview-card'; -import { Box, Grid } from '@mui/material'; +import { Box, Card, Grid, LinearProgress } from '@mui/material'; import { AssignmentStatus } from './assignment-status'; -import { Submission } from '../../../model/submission'; -import { useRouteLoaderData } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { getAssignment } from '../../../services/assignments.service'; +import { extractIdsFromBreadcrumbs } from '../../util/breadcrumbs'; +import { getLecture, getUsers } from '../../../services/lectures.service'; +import { getAllSubmissions } from '../../../services/submissions.service'; export const OverviewComponent = () => { - const { lecture, assignments, users } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: Assignment[]; - users: { instructors: string[]; tutors: string[]; students: string[] }; - }; - const { assignment, allSubmissions, latestSubmissions } = useRouteLoaderData( - 'assignment' - ) as { - assignment: Assignment; - allSubmissions: Submission[]; - latestSubmissions: Submission[]; - }; + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); + + const { data: lecture, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId), + enabled: !!lectureId + }); + + const { + data: assignment, + refetch: refetchAssignment, + isLoading: isLoadingAssignment + } = useQuery({ + queryKey: ['assignment', assignmentId], + queryFn: () => getAssignment(lectureId, assignmentId, true), + enabled: !!lectureId && !!assignmentId + }); + + const { data: latestSubmissionsNumber = 0 } = useQuery({ + queryKey: ['latestSubmissionsNumber', lectureId, assignmentId], + queryFn: async () => { + const submissions = await getAllSubmissions( + lectureId, + assignmentId, + 'latest' + ); + return submissions.length; + }, + enabled: !!lectureId && !!assignmentId + }); + + const { data: students = 0 } = useQuery({ + queryKey: ['users', lectureId], + queryFn: async () => { + const users = await getUsers(lectureId); + return users['students'].length; + }, + enabled: !!lectureId + }); - const [assignmentState, setAssignmentState] = React.useState(assignment); + if (isLoadingLecture || isLoadingAssignment) { + return ( + + + + + + ); + } - const onAssignmentChange = (assignment: Assignment) => { - setAssignmentState(assignment); + const onAssignmentChange = async () => { + await refetchAssignment(); }; return ( - + diff --git a/src/components/coursemanage/routes.tsx b/src/components/coursemanage/routes.tsx index f6b616f..8cd356a 100644 --- a/src/components/coursemanage/routes.tsx +++ b/src/components/coursemanage/routes.tsx @@ -11,17 +11,10 @@ import ErrorPage from '../util/error'; import { CourseManageComponent } from './coursemanage.component'; import { UserPermissions } from '../../services/permission.service'; import { - getAllLectures, - getLecture, - getUsers + getAllLectures } from '../../services/lectures.service'; import { enqueueSnackbar } from 'notistack'; -import { - getAllAssignments, - getAssignment -} from '../../services/assignments.service'; import { LectureComponent } from './lecture'; -import { getAllSubmissions } from '../../services/submissions.service'; import { AssignmentModalComponent } from './assignment-modal'; import { OverviewComponent } from './overview/overview'; import GradingTable, { GradingComponent } from './grading/grading'; @@ -31,6 +24,8 @@ import { FileView } from './files/file-view'; import { ManualGrading } from './grading/manual-grading'; import { EditSubmission } from './grading/edit-submission'; import { CreateSubmission } from './grading/create-submission'; +import { loadAssignment, loadLecture } from '../assignment/routes'; +import { queryClient } from '../../widgets/assignmentmanage'; const shouldReload = (request: Request) => new URL(request.url).searchParams.get('reload') === 'true'; @@ -51,38 +46,6 @@ const loadPermissions = async () => { } }; -const loadLecture = async (lectureId: number) => { - try { - const [lecture, assignments, users] = await Promise.all([ - getLecture(lectureId), - getAllAssignments(lectureId), - getUsers(lectureId) - ]); - return { lecture, assignments, users }; - } catch (error: any) { - enqueueSnackbar(error.message, { - variant: 'error' - }); - throw new Error('Could not load data!'); - } -}; - -const loadAssignment = async (lectureId: number, assignmentId: number) => { - try { - const [assignment, allSubmissions, latestSubmissions] = await Promise.all([ - getAssignment(lectureId, assignmentId), - getAllSubmissions(lectureId, assignmentId, 'none', true), - getAllSubmissions(lectureId, assignmentId, 'latest', true) - ]); - return { assignment, allSubmissions, latestSubmissions }; - } catch (error: any) { - enqueueSnackbar(error.message, { - variant: 'error' - }); - throw new Error('Could not load data!'); - } -}; - function ExamplePage({ to }) { const navigation = useNavigation(); // router navigates to new route (and loads data) const loading = navigation.state === 'loading'; @@ -115,7 +78,6 @@ export const getRoutes = () => { 'Lectures', link: params => '/' @@ -125,10 +87,10 @@ export const getRoutes = () => { loadLecture(+params.lid)} + loader={({ params }) => loadLecture(+params.lid, queryClient)} handle={{ // functions in handle have to handle undefined data (error page is displayed afterwards) - crumb: data => data?.lecture.name, + crumb: data => data?.name, link: params => `lecture/${params?.lid}/` }} > @@ -137,10 +99,10 @@ export const getRoutes = () => { id={'assignment'} path={'assignment/:aid/*'} element={} - loader={({ params }) => loadAssignment(+params.lid, +params.aid)} + loader={({ params }) => loadAssignment(+params.lid, +params.aid, queryClient)} handle={{ // functions in handle have to handle undefined data (error page is displayed afterwards) - crumb: data => data?.assignment.name, + crumb: data => data?.name, link: params => `assignment/${params.aid}/` }} > diff --git a/src/components/coursemanage/settings/settings.tsx b/src/components/coursemanage/settings/settings.tsx index 8f838b4..288f3e3 100644 --- a/src/components/coursemanage/settings/settings.tsx +++ b/src/components/coursemanage/settings/settings.tsx @@ -7,7 +7,6 @@ import { Button, Checkbox, FormControlLabel, - IconButton, InputLabel, MenuItem, Stack, @@ -21,13 +20,14 @@ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'; import { updateAssignment, - deleteAssignment + deleteAssignment, + getAssignment } from '../../../services/assignments.service'; import { enqueueSnackbar } from 'notistack'; import { Lecture } from '../../../model/lecture'; import * as yup from 'yup'; import { SectionTitle } from '../../util/section-title'; -import { useNavigate, useRouteLoaderData } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { getLateSubmissionInfo, ILateSubmissionInfo, @@ -37,8 +37,11 @@ import { FormikValues } from 'formik/dist/types'; import moment from 'moment'; import { red } from '@mui/material/colors'; import { showDialog } from '../../util/dialog-provider'; -import CloseIcon from '@mui/icons-material/Close'; import { updateMenus } from '../../../menu'; +import { extractIdsFromBreadcrumbs } from '../../util/breadcrumbs'; +import { getLecture } from '../../../services/lectures.service'; +import { useQuery } from '@tanstack/react-query'; +import { queryClient } from '../../../widgets/assignmentmanage'; const gradingBehaviourHelp = `Specifies the behaviour when a students submits an assignment.\n No Automatic Grading: No action is taken on submit.\n @@ -61,25 +64,22 @@ const validationSchema = yup.object({ .min(1, 'Students must be able to at least submit once') }); -//export interface ISettingsProps { -// root: HTMLElement; -//} - export const SettingsComponent = () => { const navigate = useNavigate(); - const { lecture, assignments } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: Assignment[]; - }; + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); - const { assignment, allSubmissions, latestSubmissions } = useRouteLoaderData( - 'assignment' - ) as { - assignment: Assignment; - allSubmissions: Submission[]; - latestSubmissions: Submission[]; - }; + const { data: lecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId), + enabled: !!lectureId + }); + + const { data: assignment } = useQuery({ + queryKey: ['assignment', assignmentId], + queryFn: () => getAssignment(lectureId, assignmentId), + enabled: !!lecture && !!assignmentId + }); const [checked, setChecked] = React.useState(assignment.due_date !== null); const [checkedLimit, setCheckedLimit] = React.useState( @@ -147,7 +147,7 @@ export const SettingsComponent = () => { } } } - if (nErrors == 0) { + if (nErrors === 0) { // error object has to be empty, otherwise submit is blocked return {}; } @@ -174,6 +174,7 @@ export const SettingsComponent = () => { updateAssignment(lecture.id, updatedAssignment).then( async response => { await updateMenus(true); + await queryClient.invalidateQueries({ queryKey: ['assignments'] }); enqueueSnackbar('Successfully Updated Assignment', { variant: 'success' }); diff --git a/src/components/coursemanage/stats/stats.tsx b/src/components/coursemanage/stats/stats.tsx index 5eebae4..7c94b2e 100644 --- a/src/components/coursemanage/stats/stats.tsx +++ b/src/components/coursemanage/stats/stats.tsx @@ -1,7 +1,7 @@ import { Lecture } from '../../../model/lecture'; import { Assignment } from '../../../model/assignment'; import { Submission } from '../../../model/submission'; -import { Box, IconButton, Tooltip } from '@mui/material'; +import { Box, Card, IconButton, LinearProgress, Tooltip } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { SectionTitle } from '../../util/section-title'; import ReplayIcon from '@mui/icons-material/Replay'; @@ -11,11 +11,12 @@ import { SubmissionTimeSeries } from './submission-timeseries'; import { GradingProgress } from './grading-progress'; import { StudentSubmissions } from './student-submissions'; import { ScoreDistribution } from './score-distribution'; -import { getUsers } from '../../../services/lectures.service'; +import { getLecture, getUsers } from '../../../services/lectures.service'; import { GradeBook } from '../../../services/gradebook'; import { AssignmentScore } from './assignment-score'; -import { getAssignmentProperties } from '../../../services/assignments.service'; -import { useRouteLoaderData } from 'react-router-dom'; +import { getAssignment, getAssignmentProperties } from '../../../services/assignments.service'; +import { extractIdsFromBreadcrumbs } from '../../util/breadcrumbs'; +import { useQuery } from '@tanstack/react-query'; export const filterUserSubmissions = ( submissions: Submission[], @@ -33,63 +34,90 @@ export interface IStatsSubComponentProps { } export const StatsComponent = () => { - const { lecture, assignments, users } = useRouteLoaderData('lecture') as { - lecture: Lecture; - assignments: Assignment[]; - users: { instructors: string[]; tutors: string[]; students: string[] }; - }; - const { assignment, allSubmissions, latestSubmissions } = useRouteLoaderData( - 'assignment' - ) as { - assignment: Assignment; - allSubmissions: Submission[]; - latestSubmissions: Submission[]; - }; + const { lectureId, assignmentId } = extractIdsFromBreadcrumbs(); + + const { data: lecture, isLoading: isLoadingLecture } = useQuery({ + queryKey: ['lecture', lectureId], + queryFn: () => getLecture(lectureId), + enabled: !!lectureId, + }); + + const { data: assignment, isLoading: isLoadingAssignment } = useQuery({ + queryKey: ['assignment', assignmentId], + queryFn: () => getAssignment(lectureId, assignmentId), + enabled: !!lectureId && !!assignmentId, + }); + + const { data: usersData, isLoading: isLoadingUsers } = useQuery({ + queryKey: ['users', lectureId], + queryFn: () => getUsers(lectureId), + enabled: !!lectureId, + }); - const [allSubmissionsState, setAllSubmissionsState] = - React.useState(allSubmissions); - const [latestSubmissionsState, setLatestSubmissionsState] = - React.useState(latestSubmissions); - const [gb, setGb] = React.useState(null as GradeBook); - const [usersState, setUsersState] = React.useState(users); + const { data: allSubmissions = [], isLoading: isLoadingAllSubmissions } = useQuery({ + queryKey: ['allSubmissions', lectureId, assignmentId], + queryFn: () => getAllSubmissions(lectureId, assignmentId, 'none', true), + enabled: !!lectureId && !!assignmentId, + }); + + const { data: latestSubmissions = [], isLoading: isLoadingLatestSubmissions } = useQuery({ + queryKey: ['latestSubmissions', lectureId, assignmentId], + queryFn: () => getAllSubmissions(lectureId, assignmentId, 'latest', true), + enabled: !!lectureId && !!assignmentId, + }); + + const [allSubmissionsState, setAllSubmissionsState] = React.useState([]); + const [latestSubmissionsState, setLatestSubmissionsState] = React.useState([]); + const [gb, setGb] = React.useState(null); + const [usersState, setUsersState] = React.useState({ students: [], tutors: [], instructors: [] }); const updateSubmissions = async () => { - setAllSubmissionsState( - await getAllSubmissions(lecture.id, assignment.id, 'none', true) - ); - setLatestSubmissionsState( - await getAllSubmissions(lecture.id, assignment.id, 'latest', true) - ); - setUsersState(await getUsers(lecture.id)); - setGb( - new GradeBook(await getAssignmentProperties(lecture.id, assignment.id)) - ); + const newAllSubmissions = await getAllSubmissions(lectureId, assignmentId, 'none', true); + const newLatestSubmissions = await getAllSubmissions(lectureId, assignmentId, 'latest', true); + const newUsers = await getUsers(lectureId); + const newGb = new GradeBook(await getAssignmentProperties(lectureId, assignmentId)); + + setAllSubmissionsState(newAllSubmissions); + setLatestSubmissionsState(newLatestSubmissions); + setUsersState(newUsers); + setGb(newGb); }; React.useEffect(() => { - getAllSubmissions(lecture.id, assignment.id, 'none', true).then( - response => { - setAllSubmissionsState(response); - } - ); - getAllSubmissions(lecture.id, assignment.id, 'latest', true).then( - response => { - setLatestSubmissionsState(response); - } - ); - }, [allSubmissions, latestSubmissions]); + if (allSubmissions.length > 0) { + setAllSubmissionsState(allSubmissions); + } + }, [allSubmissions]); React.useEffect(() => { - getUsers(lecture.id).then(response => { - setUsersState(response); - }); - }, [users]); + if (latestSubmissions.length > 0) { + setLatestSubmissionsState(latestSubmissions); + } + }, [latestSubmissions]); React.useEffect(() => { - getAssignmentProperties(lecture.id, assignment.id).then(properties => { - setGb(new GradeBook(properties)); - }); - }, []); + if (Object.keys(usersData).length > 0) { + setUsersState(usersData); + } + }, [usersData]); + + React.useEffect(() => { + if (lecture && assignment) { + getAssignmentProperties(lecture.id, assignment.id).then(properties => { + setGb(new GradeBook(properties)); + }); + } + }, [lecture, assignment]); + + if (isLoadingLecture || isLoadingAssignment || isLoadingUsers || isLoadingAllSubmissions || isLoadingLatestSubmissions) { + return ( + + + + + + ); + } return ( diff --git a/src/components/notebook/create-assignment/creation-switch.tsx b/src/components/notebook/create-assignment/creation-switch.tsx index 88feaee..e31b2c9 100644 --- a/src/components/notebook/create-assignment/creation-switch.tsx +++ b/src/components/notebook/create-assignment/creation-switch.tsx @@ -86,7 +86,13 @@ export class CreationModeSwitch extends React.Component { } else { currentLayout.widgets.map(w => { if (w instanceof CreationWidget || w instanceof ErrorWidget) { - currentLayout.removeWidget(w); + try { + currentLayout.removeWidget(w); + } catch(error: any) { + console.log("Could not remove widget of cell: " + w.cell.id) + console.log("Error: " + error) + } + } }); } diff --git a/src/components/notebook/manual-grading/grading-switch.tsx b/src/components/notebook/manual-grading/grading-switch.tsx index c22ee1a..4b28459 100644 --- a/src/components/notebook/manual-grading/grading-switch.tsx +++ b/src/components/notebook/manual-grading/grading-switch.tsx @@ -160,7 +160,12 @@ export class GradingModeSwitch extends React.Component { } else { currentLayout.widgets.map(w => { if (w instanceof DataWidget || w instanceof GradeWidget) { - currentLayout.removeWidget(w); + try { + currentLayout.removeWidget(w); + } catch(error: any) { + console.log("Could not remove widget of cell:" + w.cell.id) + console.log("Error: " + error) + } } }); } diff --git a/src/components/util/breadcrumbs.tsx b/src/components/util/breadcrumbs.tsx index ad0c20e..da59379 100644 --- a/src/components/util/breadcrumbs.tsx +++ b/src/components/util/breadcrumbs.tsx @@ -3,8 +3,6 @@ import Link, { LinkProps } from '@mui/material/Link'; import { Link as RouterLink, useMatches, - useParams, - useLoaderData, Outlet, useLocation } from 'react-router-dom'; @@ -21,7 +19,6 @@ export const Page = ({ id }: { id: string }) => { .filter(v => v.length > 0) .slice(0, 2) .join('/'); - console.log(`Storing path: ${pathname}`); storeString(`${id}-react-router-path`, pathname); return ( @@ -54,7 +51,6 @@ export function LinkRouter(props: ILinkRouterProps) { export const RouterBreadcrumbs = () => { const pathname = useLocation().pathname.replace(/\/$/, ''); const matches = useMatches(); - console.log(`Navigating to: ${pathname}`); const crumbs = matches // first get rid of any matches that don't have handle and crumb @@ -63,14 +59,10 @@ export const RouterBreadcrumbs = () => { // data to each one .map((match: any) => match.handle.crumb(match.data)); - console.log(crumbs); - const links = matches .filter((match: any) => Boolean(match.handle?.link)) .map((match: any) => match.handle.link(match.params)); - console.log(links); - return ( { ); }; + +export const extractIdsFromBreadcrumbs = () => { + const matches = useMatches(); + const links = matches + .filter((match: any) => Boolean(match.handle?.link)) + .map((match: any) => match.handle.link(match.params)); + + let lectureId; + let assignmentId; + + for (let i = 0; i < links.length; i++) { + if (links[i].includes("lecture")) { + const lecture = links[i].split("/"); + lectureId = parseInt(lecture[1], 10); + } else if (links[i].includes("assignment")) { + const assignment = links[i].split("/"); + assignmentId = parseInt(assignment[1], 10); + } + } + + return { lectureId, assignmentId }; +}; \ No newline at end of file diff --git a/src/components/util/deadline.tsx b/src/components/util/deadline.tsx index 17efeef..8ef6bb8 100644 --- a/src/components/util/deadline.tsx +++ b/src/components/util/deadline.tsx @@ -27,6 +27,7 @@ import AlarmAddIcon from '@mui/icons-material/AlarmAdd'; import { SubmissionPeriod } from '../../model/submissionPeriod'; import { utcToLocalFormat } from '../../services/datetime.service'; import { GlobalObjects } from '../../index'; +import { useQuery } from '@tanstack/react-query'; export interface IDeadlineProps { due_date: string | null; @@ -211,11 +212,13 @@ export function DeadlineDetail(props: IDeadlineDetailProps) { } const [open, setOpen] = React.useState(true); - const [date, setDate] = React.useState( - props.due_date !== null - ? moment.utc(props.due_date).local().toDate() - : undefined - ); + const {data: date = undefined } = useQuery({ + queryKey: ['date'], + queryFn: () => { + props.due_date != null ? moment.utc(props.due_date).local().toDate() : undefined + } + }); + const [displayDuration, setDisplayDuration] = React.useState( getDisplayDate(date, false) ); diff --git a/src/components/util/dialog-provider.tsx b/src/components/util/dialog-provider.tsx index 8b47861..cd47c93 100644 --- a/src/components/util/dialog-provider.tsx +++ b/src/components/util/dialog-provider.tsx @@ -68,7 +68,10 @@ export const DialogProvider = (props: IDialogProviderProps) => { {title} - {message} + diff --git a/src/components/util/dialog.tsx b/src/components/util/dialog.tsx index 1e893c2..8b4b74c 100644 --- a/src/components/util/dialog.tsx +++ b/src/components/util/dialog.tsx @@ -33,11 +33,14 @@ import { TooltipProps, tooltipClasses, Typography, - Snackbar + Snackbar, + Modal, + Alert, + AlertTitle } from '@mui/material'; import { Assignment } from '../../model/assignment'; -import { LoadingButton } from '@mui/lab'; -import SearchIcon from '@mui/icons-material/Search'; +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import SettingsIcon from '@mui/icons-material/Settings'; import { createAssignment } from '../../services/assignments.service'; import { Lecture } from '../../model/lecture'; @@ -53,6 +56,10 @@ import styled from '@mui/system/styled'; import MuiAlert, { AlertProps } from '@mui/material/Alert'; import { updateMenus } from '../../menu'; import { GraderLoadingButton } from './loading-button'; +import { FilesList } from './file-list'; +import { extractRelativePaths, getFiles, lectureBasePath } from '../../services/file.service'; +import InfoIcon from '@mui/icons-material/Info'; +import { queryClient } from '../../widgets/assignmentmanage'; const gradingBehaviourHelp = `Specifies the behaviour when a students submits an assignment.\n No Automatic Grading: No action is taken on submit.\n @@ -275,6 +282,7 @@ export const CreateDialog = (props: ICreateDialogProps) => { async a => { await updateMenus(true); props.handleSubmit(a); + await queryClient.invalidateQueries({ queryKey: ['assignments'] }); }, error => { @@ -494,14 +502,94 @@ export const CreateDialog = (props: ICreateDialogProps) => { ); }; +const InfoModal = () => { + const [open, setOpen] = React.useState(false); + const handleOpen = () => { + setOpen(true); + }; + const handleClose = () => { + setOpen(false); + }; + return ( + + + + + + + Selecting Files to Push + + Info + If you have made changes to multiple files in your source directory and wish to push only specific + files to the remote repository, you can toggle the 'Select files to commit' button. This allows you to + choose the files you want to push. Your students will then be able to view only the changes in files you have selected. + If you do not use this option, all changed files from the source repository will be pushed, and students will see all the changes. + + Close + + + + ); +}; + + export interface ICommitDialogProps { - handleCommit: (msg: string) => void; + handleCommit: (msg: string, selectedFiles?: string[]) => void; children: React.ReactNode; + lecture?: Lecture; + assignment?: Assignment; } export const CommitDialog = (props: ICommitDialogProps) => { const [open, setOpen] = React.useState(false); const [message, setMessage] = React.useState(''); + const [selectedDir, setSelectedDir] = React.useState('source'); + const [filesListVisible, setFilesListVisible] = React.useState(false); + const [selectedFiles, setSelectedFiles] = React.useState(); + const path = `${lectureBasePath}${props.lecture.code}/${selectedDir}/${props.assignment.id}`; + + const fetchFilesForSelectedDir = async () => { + try { + const files = await getFiles(path); + const filePaths = files.flatMap(file => + extractRelativePaths(file, 'source') + ); + setSelectedFiles(filePaths); + } catch (error) { + console.error('Error fetching files:', error); + } + }; + + const toggleFilesList = () => { + setFilesListVisible(!filesListVisible); + fetchFilesForSelectedDir(); + }; + + const handleFileSelectChange = (filePath: string, isSelected: boolean) => { + // console.log(` ${filePath} - ${isSelected}`); + setSelectedFiles(prevSelectedFiles => { + if (isSelected) { + if (!prevSelectedFiles.includes(filePath)) { + return [...prevSelectedFiles, filePath]; + } + } else { + return prevSelectedFiles.filter(file => file !== filePath); + } + return prevSelectedFiles; + }); + }; return ( @@ -513,8 +601,24 @@ export const CommitDialog = (props: ICommitDialogProps) => { fullWidth={true} maxWidth={'sm'} > - Commit Files + + Commit Files + + + + {filesListVisible ? : } + Choose files to commit + + {filesListVisible && ( + + )} { variant="outlined" onClick={() => { setOpen(false); + toggleFilesList(); }} > Cancel @@ -542,8 +647,10 @@ export const CommitDialog = (props: ICommitDialogProps) => { type="submit" disabled={message === ''} onClick={() => { - props.handleCommit(message); + // console.log("See selected files: " + selectedFiles); + props.handleCommit(message, selectedFiles); setOpen(false); + toggleFilesList(); }} > Commit @@ -555,7 +662,6 @@ export const CommitDialog = (props: ICommitDialogProps) => { }; export interface IReleaseDialogProps extends ICommitDialogProps { - assignment: Assignment; handleRelease: () => void; } diff --git a/src/components/util/error.tsx b/src/components/util/error.tsx index 8274205..2d39595 100644 --- a/src/components/util/error.tsx +++ b/src/components/util/error.tsx @@ -1,20 +1,115 @@ import * as React from 'react'; -import { useRouteError } from 'react-router-dom'; +import { + Typography, + Button, + Container, + Grid, + Box +} from '@mui/material'; +import { useNavigate, useRouteError } from 'react-router-dom'; import { storeString } from '../../services/storage.service'; export default function ErrorPage({ id }: { id: string }) { const error: any = useRouteError(); + const navigate = useNavigate(); console.error(error); - console.log('Storing path: /'); storeString(`${id}-react-router-path`, '/'); return ( - - Oops! - Sorry, an unexpected error has occurred. - - {error.statusText || error.message} - - + + + + + Sorry! + + + An unexpected error has occurred. + + + Details + + + {error.statusText || error.message} + + + navigate(-1)} + > + Back + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/src/components/util/file-item.tsx b/src/components/util/file-item.tsx index 7acaa0d..342523d 100644 --- a/src/components/util/file-item.tsx +++ b/src/components/util/file-item.tsx @@ -6,32 +6,147 @@ import { ListItemText, Tooltip, Stack, - Typography + Typography, + Checkbox, + Chip } from '@mui/material'; import InsertDriveFileRoundedIcon from '@mui/icons-material/InsertDriveFileRounded'; import WarningIcon from '@mui/icons-material/Warning'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; -import { Contents } from '@jupyterlab/services'; import DangerousIcon from '@mui/icons-material/Dangerous'; -import { File, getRelativePathAssignment } from '../../services/file.service'; +import { + File, + getRelativePath, + getRemoteFileStatus +} from '../../services/file.service'; +import { Lecture } from '../../model/lecture'; +import { Assignment } from '../../model/assignment'; +import { RepoType } from './repo-type'; +import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; +import CheckIcon from '@mui/icons-material/Check'; +import PublishRoundedIcon from '@mui/icons-material/PublishRounded'; +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; +import { RemoteFileStatus } from '../../model/remoteFileStatus'; interface IFileItemProps { file: File; + lecture?: Lecture; + assignment?: Assignment; inContained: (file: string) => boolean; missingFiles?: File[]; openFile: (path: string) => void; allowFiles?: boolean; + checkboxes: boolean; + onFileSelectChange?: (filePath: string, isSelected: boolean) => void; + checkStatus?: boolean; // check if file is up to date with remote git repo } const FileItem = ({ file, + lecture, + assignment, inContained, openFile, allowFiles, - missingFiles + missingFiles, + checkboxes, + onFileSelectChange, + checkStatus = false // Default is false if not provided }: IFileItemProps) => { const inMissing = (filePath: string) => { - return missingFiles.some(missingFile => missingFile.path === filePath); + return missingFiles?.some(missingFile => missingFile.path === filePath); + }; + + const [isSelected, setIsSelected] = React.useState(true); + + const fileStatusQueryOptions: UseQueryOptions = { + queryKey: ['fileStatus', lecture?.id, assignment?.id, file.path], + queryFn: () => + getRemoteFileStatus( + lecture, + assignment, + RepoType.SOURCE, + getRelativePath(file.path, 'source'), + true + ) as Promise, + enabled: checkStatus && !!lecture && !!assignment, // Enable only if checkStatus is true + staleTime: 3000 + }; + + const fileRemoteStatusResponse = useQuery(fileStatusQueryOptions); + + if (fileRemoteStatusResponse.isError) { + console.error( + 'could not fetch remote status: ' + fileRemoteStatusResponse.error.message + ); + } + + const fileRemoteStatus: RemoteFileStatus.StatusEnum | undefined = + fileRemoteStatusResponse.data?.status; + + const getFileRemoteStatusText = (status: RemoteFileStatus.StatusEnum) => { + if (status === RemoteFileStatus.StatusEnum.UpToDate) { + return 'The local file is up to date with the file from remote repository.'; + } else if (status === RemoteFileStatus.StatusEnum.PushNeeded) { + return 'You have made changes to this file locally, a push is needed.'; + } else if (status === RemoteFileStatus.StatusEnum.NoRemoteRepo) { + return 'There is no remote repository yet. Push your assignment to create it.'; + } else { + return 'The local and remote file are divergent.'; + } + }; + + const getStatusChip = (status: RemoteFileStatus.StatusEnum) => { + if (status === RemoteFileStatus.StatusEnum.UpToDate) { + return ( + } + /> + ); + } else if (status === RemoteFileStatus.StatusEnum.PushNeeded) { + return ( + } + /> + ); + } else if (status === RemoteFileStatus.StatusEnum.NoRemoteRepo) { + return ( + } + /> + ); + } else { + return ( + } + /> + ); + } + }; + + const toggleSelection = () => { + setIsSelected(prevState => { + const nextState = !prevState; + // used only with checkboxes -> in source directory + onFileSelectChange?.(getRelativePath(file.path, 'source'), nextState); + return nextState; + }); }; const extraFileHelp = @@ -39,18 +154,29 @@ const FileItem = ({ const missingFileHelp = 'This file should be part of your assignment! Did you delete it?'; - //console.log("Missing files (file-item): " + missingFiles.map(f => f.path)); return ( + {checkboxes && ( + + + + )} openFile(file.path)} dense={true}> - + {!checkboxes && ( + + )} {file.name}} secondary={ + {checkboxes && checkStatus && fileRemoteStatus && ( + + {getStatusChip(fileRemoteStatus)} + + )} {inMissing(file.path) && ( @@ -63,7 +189,7 @@ const FileItem = ({ )} { - {!inContained(getRelativePathAssignment(file.path)) && + {!inContained(getRelativePath(file.path, 'assignments')) && !allowFiles && ( diff --git a/src/components/util/file-list.tsx b/src/components/util/file-list.tsx index a3e9afe..2f6b381 100644 --- a/src/components/util/file-list.tsx +++ b/src/components/util/file-list.tsx @@ -1,25 +1,13 @@ import React from 'react'; -import { - Box, - Card, - List, - ListItem, - ListItemIcon, - ListItemText, - Paper, - Tooltip, - Typography -} from '@mui/material'; -import { Contents } from '@jupyterlab/services'; -import IModel = Contents.IModel; -import { Stack, SxProps } from '@mui/system'; +import { Card, List, Paper, Typography } from '@mui/material'; +import { SxProps } from '@mui/system'; import { Theme } from '@mui/material/styles'; import { getFiles, openFile, File, - extractRelativePathsAssignment, - getRelativePathAssignment, + extractRelativePaths, + getRelativePath, lectureBasePath } from '../../services/file.service'; import { grey } from '@mui/material/colors'; @@ -27,6 +15,7 @@ import FileItem from './file-item'; import FolderItem from './folder-item'; import { Assignment } from '../../model/assignment'; import { Lecture } from '../../model/lecture'; +import { useQuery } from '@tanstack/react-query'; interface IFileListProps { path: string; @@ -35,13 +24,22 @@ interface IFileListProps { assignment?: Assignment; lecture?: Lecture; missingFiles?: File[]; + checkboxes: boolean; + onFileSelectChange?: (filePath: string, isSelected: boolean) => void; + checkStatus?: boolean; // check if files in list are up to date with remote git repo } export const FilesList = (props: IFileListProps) => { - const [files, setFiles] = React.useState([]); + const { data: files = [], refetch } = useQuery({ + queryKey: ['files', props.path], + queryFn: () => getFiles(props.path), + // Disable automatic refetching, since we want to subscribe directyly on property changes. + refetchOnMount: false, + refetchInterval: false + }); React.useEffect(() => { - getFiles(props.path).then(files => setFiles(files)); + refetch(); }, [props]); const inContained = (file: string) => { @@ -51,9 +49,13 @@ export const FilesList = (props: IFileListProps) => { return true; }; - const generateItems = (files: File[]) => { + const handleFileSelectChange = (filePath: string, isSelected: boolean) => { + props.onFileSelectChange(filePath, isSelected); + }; + + const generateItems = (files: File[], handleFileSelectChange) => { const filePaths = files.flatMap(file => - extractRelativePathsAssignment(file) + extractRelativePaths(file, 'assignments') ); const missingFiles: File[] = (props.shouldContain && @@ -72,7 +74,7 @@ export const FilesList = (props: IFileListProps) => { []; const missingFilesTopOrder = missingFiles.filter(missingFile => { - const relativePath = getRelativePathAssignment(missingFile.path); + const relativePath = getRelativePath(missingFile.path, 'assignments'); return !relativePath.includes('/'); }); @@ -82,10 +84,15 @@ export const FilesList = (props: IFileListProps) => { ); } else { @@ -93,10 +100,15 @@ export const FilesList = (props: IFileListProps) => { ); } @@ -113,7 +125,7 @@ export const FilesList = (props: IFileListProps) => { No Files Found ) : ( - {generateItems(files)} + {generateItems(files, props.onFileSelectChange)} )} diff --git a/src/components/util/folder-item.tsx b/src/components/util/folder-item.tsx index f5e75dd..e700fe3 100644 --- a/src/components/util/folder-item.tsx +++ b/src/components/util/folder-item.tsx @@ -5,33 +5,26 @@ import { ListItemIcon, ListItemText, Collapse, - Stack, - Tooltip, Typography, List } from '@mui/material'; -import { Contents } from '@jupyterlab/services'; -import IModel = Contents.IModel; import FolderIcon from '@mui/icons-material/Folder'; import FileItem from './file-item'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import { File, getFiles } from '../../services/file.service'; - -interface IFolderItemProps { - folder: File; - inContained: (file: string) => boolean; - openFile: (path: string) => void; - allowFiles?: boolean; - missingFiles?: File[]; -} +import { getFiles } from '../../services/file.service'; const FolderItem = ({ folder, + lecture, + assigment, missingFiles, inContained, openFile, - allowFiles + allowFiles, + checkboxes, + onFileSelectChange, + checkStatus }) => { const [open, setOpen] = useState(false); @@ -65,8 +58,6 @@ const FolderItem = ({ setOpen(!open); }; - //console.log("Folder " + folder.name + " contents: " + folder.content.map(f => f.path)); - return ( <> @@ -86,19 +77,29 @@ const FolderItem = ({ ) : ( ) )} diff --git a/src/index.ts b/src/index.ts index 3a9ae91..d578636 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,11 +59,10 @@ import { HintWidget } from './components/notebook/student-plugin/hint-widget'; import { DeadlineWidget } from './components/notebook/student-plugin/deadline-widget'; import { lectureSubPaths } from './services/file.service'; import IModel = Contents.IModel; -import { Assignment } from './model/assignment'; -import { Lecture } from './model/lecture'; -import { getAllLectures } from './services/lectures.service'; -import { getLabel, updateMenus } from './menu'; +import { updateMenus } from './menu'; import { loadString } from './services/storage.service'; +import { HTTPError } from './services/request.service'; +import { ErrorRounded } from '@mui/icons-material'; export namespace AssignmentsCommandIDs { export const create = 'assignments:create'; @@ -89,34 +88,6 @@ namespace ShowHintIDs { export const show = 'notebookplugin:show-hint'; } -namespace GradingCommandIDs { - export const create = 'grading:create'; - - export const open = 'grading:open'; -} - -namespace ManualGradeCommandIDs { - export const create = 'manualgrade:create'; - - export const open = 'manualgrade:open'; -} - -namespace CreateSubmissionCommandsID { - export const create = 'create:create'; - - export const open = 'create:open'; -} - -namespace FeedbackCommandIDs { - export const create = 'feedback:create'; - - export const open = 'feedback:open'; -} - -namespace DeadlineCommandIDs { - export const open = 'deadline:open'; -} - export class GlobalObjects { static commands: CommandRegistry; static docRegistry: DocumentRegistry; @@ -129,6 +100,123 @@ export class GlobalObjects { static courseManageMenu: Menu; } +const createCourseManagementOpenCommand = (app: JupyterFrontEnd, launcher: ILauncher, courseManageTracker: WidgetTracker>) => { + const command = CourseManageCommandIDs.open; + app.commands.addCommand(command, { + label: args => + args['label'] ? (args['label'] as string) : 'Course Management', + execute: async args => { + let gradingWidget = courseManageTracker.currentWidget; + if (!gradingWidget) { + gradingWidget = await app.commands.execute( + CourseManageCommandIDs.create + ); + } + + let path = args?.path as string; + if (args?.path === undefined) { + const savedPath = loadString('course-manage-react-router-path'); + if (savedPath !== null && savedPath !== '') { + path = savedPath; + } else { + path = '/'; + } + } + await gradingWidget.content.router.navigate(path); + + if (!gradingWidget.isAttached) { + // Attach the widget to the main work area if it's not there + app.shell.add(gradingWidget, 'main'); + } + // Activate the widget + app.shell.activateById(gradingWidget.id); + }, + icon: args => (args['path'] ? undefined : checkIcon) + }); + // Add the command to the launcher + console.log('Add course management launcher'); + launcher.add({ + command: command, + category: 'Assignments', + rank: 0 + }); +} + +//Creation of in-cell widget for create assignment +const connectTrackerSignals = (tracker: INotebookTracker) => { + tracker.currentChanged.connect(async () => { + const notebookPanel = tracker.currentWidget; + //Notebook not yet loaded + if (notebookPanel === null) { + return; + } + const notebook: Notebook = tracker.currentWidget.content; + const mode = false; + + notebookPanel.context.ready.then(() => { + //Creation of widget switch + const switcher: NotebookModeSwitch = new NotebookModeSwitch( + mode, + notebookPanel, + notebook + ); + + tracker.currentWidget.toolbar.insertItem(10, 'Mode', switcher); + + //Creation of deadline widget + const deadlineWidget = new DeadlineWidget( + tracker.currentWidget.context.path + ); + tracker.currentWidget.toolbar.insertItem( + 11, + 'Deadline', + deadlineWidget + ); + }); + }, this); + + tracker.activeCellChanged.connect(() => { + const notebookPanel: NotebookPanel = tracker.currentWidget; + //Notebook not yet loaded + if (notebookPanel === null) { + return; + } + const notebook: Notebook = tracker.currentWidget.content; + const contentsModel: Omit = + notebookPanel.context.contentsModel; + if (contentsModel === null) { + return; + } + const notebookPaths: string[] = contentsModel.path.split('/'); + + if (notebookPaths[lectureSubPaths + 1] === 'manualgrade') { + return; + } + + let switcher: any = null; + (notebookPanel.toolbar.layout as PanelLayout).widgets.map(w => { + if (w instanceof NotebookModeSwitch) { + switcher = w; + } + }); + + const cell: Cell = notebook.activeCell; + + //check if in creationmode and new cell was inserted + if ( + switcher.mode && + (cell.layout as PanelLayout).widgets.every(w => { + return !(w instanceof CreationWidget); + }) + ) { + (cell.layout as PanelLayout).insertWidget( + 0, + new CreationWidget(cell) + ); + } + }, this); +}; + /** * Initialization data for the grading extension. */ @@ -204,81 +292,6 @@ const extension: JupyterFrontEndPlugin = { name: () => 'grader-coursemanage' }); - //Creation of in-cell widget for create assignment - const connectTrackerSignals = (tracker: INotebookTracker) => { - tracker.currentChanged.connect(async () => { - const notebookPanel = tracker.currentWidget; - //Notebook not yet loaded - if (notebookPanel === null) { - return; - } - const notebook: Notebook = tracker.currentWidget.content; - const mode = false; - - notebookPanel.context.ready.then(() => { - //Creation of widget switch - const switcher: NotebookModeSwitch = new NotebookModeSwitch( - mode, - notebookPanel, - notebook - ); - - tracker.currentWidget.toolbar.insertItem(10, 'Mode', switcher); - - //Creation of deadline widget - const deadlineWidget = new DeadlineWidget( - tracker.currentWidget.context.path - ); - tracker.currentWidget.toolbar.insertItem( - 11, - 'Deadline', - deadlineWidget - ); - }); - }, this); - - tracker.activeCellChanged.connect(() => { - const notebookPanel: NotebookPanel = tracker.currentWidget; - //Notebook not yet loaded - if (notebookPanel === null) { - return; - } - const notebook: Notebook = tracker.currentWidget.content; - const contentsModel: Omit = - notebookPanel.context.contentsModel; - if (contentsModel === null) { - return; - } - const notebookPaths: string[] = contentsModel.path.split('/'); - - if (notebookPaths[lectureSubPaths + 1] === 'manualgrade') { - return; - } - - let switcher: any = null; - (notebookPanel.toolbar.layout as PanelLayout).widgets.map(w => { - if (w instanceof NotebookModeSwitch) { - switcher = w; - } - }); - - const cell: Cell = notebook.activeCell; - - //check if in creationmode and new cell was inserted - if ( - switcher.mode && - (cell.layout as PanelLayout).widgets.every(w => { - return !(w instanceof CreationWidget); - }) - ) { - (cell.layout as PanelLayout).insertWidget( - 0, - new CreationWidget(cell) - ); - } - }, this); - }; - /* ##### Course Manage View Widget ##### */ let command: string = CourseManageCommandIDs.create; app.commands.addCommand(command, { @@ -339,47 +352,7 @@ const extension: JupyterFrontEndPlugin = { cmMenu.title.label = 'Course Management'; mainMenu.addMenu(cmMenu, false, { rank: 210 }); - command = CourseManageCommandIDs.open; - app.commands.addCommand(command, { - label: args => - args['label'] ? (args['label'] as string) : 'Course Management', - execute: async args => { - let gradingWidget = courseManageTracker.currentWidget; - if (!gradingWidget) { - gradingWidget = await app.commands.execute( - CourseManageCommandIDs.create - ); - } - - let path = args?.path as string; - if (args?.path === undefined) { - const savedPath = loadString('course-manage-react-router-path'); - if (savedPath !== null && savedPath !== '') { - console.log(`Restoring path: ${savedPath}`); - path = savedPath; - } else { - path = '/'; - } - } - await gradingWidget.content.router.navigate(path); - - if (!gradingWidget.isAttached) { - // Attach the widget to the main work area if it's not there - app.shell.add(gradingWidget, 'main'); - } - // Activate the widget - app.shell.activateById(gradingWidget.id); - }, - icon: args => (args['path'] ? undefined : checkIcon) - }); - - // Add the command to the launcher - console.log('Add course management launcher'); - launcher.add({ - command: command, - category: 'Assignments', - rank: 0 - }); + createCourseManagementOpenCommand(app, launcher, courseManageTracker) } // add Menu to JupyterLab main menu @@ -410,7 +383,6 @@ const extension: JupyterFrontEndPlugin = { 'assignment-manage-react-router-path' ); if (savedPath !== null && savedPath !== '') { - console.log(`Restoring path: ${savedPath}`); path = savedPath; } else { path = '/'; @@ -437,11 +409,12 @@ const extension: JupyterFrontEndPlugin = { rank: 0 }); }) - .catch(_ => + .catch((error: Error) => { showErrorMessage( - 'Grader Service Unavailable', - 'Could not connect to the grader service! Please contact your system administrator!' - ) + 'Grader Labextension Disabled', + 'Please restart your server: ' + error.message + ) + } ); command = NotebookExecuteIDs.run; diff --git a/src/menu.ts b/src/menu.ts index bc5a2d9..b143b34 100644 --- a/src/menu.ts +++ b/src/menu.ts @@ -59,7 +59,6 @@ export const updateMenus = async (reload: boolean = false) => { }); }); aMenu.update(); - console.log('Updated assignment menu'); if (cmMenu) { cmMenu.clearItems(); @@ -91,6 +90,5 @@ export const updateMenus = async (reload: boolean = false) => { }); }); cmMenu.update(); - console.log('Updated course manage menu'); } }; diff --git a/src/model/assignment.ts b/src/model/assignment.ts index ed24b47..da79f06 100644 --- a/src/model/assignment.ts +++ b/src/model/assignment.ts @@ -1,9 +1,7 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,35 +9,38 @@ */ import { AssignmentSettings } from './assignmentSettings'; -export interface Assignment { - id?: number; - name?: string; - type?: Assignment.TypeEnum; - due_date?: string; - status?: Assignment.StatusEnum; - points?: number; - automatic_grading?: Assignment.AutomaticGradingEnum; - max_submissions?: number; - allow_files?: boolean; - settings?: AssignmentSettings; + +export interface Assignment { + id?: number; + name?: string; + type?: Assignment.TypeEnum; + due_date?: string; + status?: Assignment.StatusEnum; + points?: number; + automatic_grading?: Assignment.AutomaticGradingEnum; + max_submissions?: number; + allow_files?: boolean; + settings?: AssignmentSettings; } export namespace Assignment { - export type TypeEnum = 'user' | 'group'; - export const TypeEnum = { - User: 'user' as TypeEnum, - Group: 'group' as TypeEnum - }; - export type StatusEnum = 'created' | 'pushed' | 'released' | 'complete'; - export const StatusEnum = { - Created: 'created' as StatusEnum, - Pushed: 'pushed' as StatusEnum, - Released: 'released' as StatusEnum, - Complete: 'complete' as StatusEnum - }; - export type AutomaticGradingEnum = 'unassisted' | 'auto' | 'full_auto'; - export const AutomaticGradingEnum = { - Unassisted: 'unassisted' as AutomaticGradingEnum, - Auto: 'auto' as AutomaticGradingEnum, - FullAuto: 'full_auto' as AutomaticGradingEnum - }; + export type TypeEnum = 'user' | 'group'; + export const TypeEnum = { + User: 'user' as TypeEnum, + Group: 'group' as TypeEnum + }; + export type StatusEnum = 'created' | 'pushed' | 'released' | 'complete'; + export const StatusEnum = { + Created: 'created' as StatusEnum, + Pushed: 'pushed' as StatusEnum, + Released: 'released' as StatusEnum, + Complete: 'complete' as StatusEnum + }; + export type AutomaticGradingEnum = 'unassisted' | 'auto' | 'full_auto'; + export const AutomaticGradingEnum = { + Unassisted: 'unassisted' as AutomaticGradingEnum, + Auto: 'auto' as AutomaticGradingEnum, + FullAuto: 'full_auto' as AutomaticGradingEnum + }; } + + diff --git a/src/model/assignmentDetail.ts b/src/model/assignmentDetail.ts index 05396ba..05f1822 100644 --- a/src/model/assignmentDetail.ts +++ b/src/model/assignmentDetail.ts @@ -1,9 +1,7 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,35 +9,38 @@ */ import { Submission } from './submission'; -export interface AssignmentDetail { - id?: number; - name?: string; - type?: AssignmentDetail.TypeEnum; - due_date?: string; - status?: AssignmentDetail.StatusEnum; - points?: number; - automatic_grading?: AssignmentDetail.AutomaticGradingEnum; - max_submissions?: number; - allow_files?: boolean; - submissions?: Array; + +export interface AssignmentDetail { + id?: number; + name?: string; + type?: AssignmentDetail.TypeEnum; + due_date?: string; + status?: AssignmentDetail.StatusEnum; + points?: number; + automatic_grading?: AssignmentDetail.AutomaticGradingEnum; + max_submissions?: number; + allow_files?: boolean; + submissions?: Array; } export namespace AssignmentDetail { - export type TypeEnum = 'user' | 'group'; - export const TypeEnum = { - User: 'user' as TypeEnum, - Group: 'group' as TypeEnum - }; - export type StatusEnum = 'created' | 'pushed' | 'released' | 'complete'; - export const StatusEnum = { - Created: 'created' as StatusEnum, - Pushed: 'pushed' as StatusEnum, - Released: 'released' as StatusEnum, - Complete: 'complete' as StatusEnum - }; - export type AutomaticGradingEnum = 'unassisted' | 'auto' | 'full_auto'; - export const AutomaticGradingEnum = { - Unassisted: 'unassisted' as AutomaticGradingEnum, - Auto: 'auto' as AutomaticGradingEnum, - FullAuto: 'full_auto' as AutomaticGradingEnum - }; + export type TypeEnum = 'user' | 'group'; + export const TypeEnum = { + User: 'user' as TypeEnum, + Group: 'group' as TypeEnum + }; + export type StatusEnum = 'created' | 'pushed' | 'released' | 'complete'; + export const StatusEnum = { + Created: 'created' as StatusEnum, + Pushed: 'pushed' as StatusEnum, + Released: 'released' as StatusEnum, + Complete: 'complete' as StatusEnum + }; + export type AutomaticGradingEnum = 'unassisted' | 'auto' | 'full_auto'; + export const AutomaticGradingEnum = { + Unassisted: 'unassisted' as AutomaticGradingEnum, + Auto: 'auto' as AutomaticGradingEnum, + FullAuto: 'full_auto' as AutomaticGradingEnum + }; } + + diff --git a/src/model/assignmentSettings.ts b/src/model/assignmentSettings.ts index f2d3cca..d572a28 100644 --- a/src/model/assignmentSettings.ts +++ b/src/model/assignmentSettings.ts @@ -1,9 +1,7 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -11,6 +9,8 @@ */ import { SubmissionPeriod } from './submissionPeriod'; -export interface AssignmentSettings { - late_submission?: Array; + +export interface AssignmentSettings { + late_submission?: Array; } + diff --git a/src/model/errorMessage.ts b/src/model/errorMessage.ts index abc9b10..f5fa18b 100644 --- a/src/model/errorMessage.ts +++ b/src/model/errorMessage.ts @@ -1,19 +1,19 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ -export interface ErrorMessage { - code: number; - error: string; - path: string; - message?: string; - traceback?: string; + +export interface ErrorMessage { + code: number; + error: string; + path: string; + message?: string; + traceback?: string; } + diff --git a/src/model/lecture.ts b/src/model/lecture.ts index 10a7922..bbe7bf3 100644 --- a/src/model/lecture.ts +++ b/src/model/lecture.ts @@ -1,18 +1,18 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ -export interface Lecture { - id?: number; - name?: string; - code?: string; - complete?: boolean; + +export interface Lecture { + id?: number; + name?: string; + code?: string; + complete?: boolean; } + diff --git a/src/model/remoteFileStatus.ts b/src/model/remoteFileStatus.ts new file mode 100644 index 0000000..6e7a450 --- /dev/null +++ b/src/model/remoteFileStatus.ts @@ -0,0 +1,26 @@ +/** + * Grader Extension API Schemas + * + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +export interface RemoteFileStatus { + status: RemoteFileStatus.StatusEnum; +} +export namespace RemoteFileStatus { + export type StatusEnum = 'UP_TO_DATE' | 'DIVERGENT' | 'PULL_NEEDED' | 'PUSH_NEEDED' | 'NO_REMOTE_REPO'; + export const StatusEnum = { + UpToDate: 'UP_TO_DATE' as StatusEnum, + Divergent: 'DIVERGENT' as StatusEnum, + PullNeeded: 'PULL_NEEDED' as StatusEnum, + PushNeeded: 'PUSH_NEEDED' as StatusEnum, + NoRemoteRepo: 'NO_REMOTE_REPO' as StatusEnum + }; +} + + diff --git a/src/model/submission.ts b/src/model/submission.ts index 1ca8bf6..bc40c06 100644 --- a/src/model/submission.ts +++ b/src/model/submission.ts @@ -1,63 +1,51 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ -export interface Submission { - id?: number; - submitted_at?: string; - auto_status?: Submission.AutoStatusEnum; - manual_status?: Submission.ManualStatusEnum; - username?: string; - grading_score?: number; - score_scaling?: number; - score?: number; - assignid?: number; - commit_hash?: string; - feedback_status?: Submission.FeedBackStatusEnum; - edited?: boolean; + +export interface Submission { + id?: number; + submitted_at?: string; + auto_status?: Submission.AutoStatusEnum; + manual_status?: Submission.ManualStatusEnum; + username?: string; + grading_score?: number; + score_scaling?: number; + score?: number; + assignid?: number; + commit_hash?: string; + feedback_status?: Submission.FeedbackStatusEnum; + edited?: boolean; } export namespace Submission { - export type AutoStatusEnum = - | 'not_graded' - | 'pending' - | 'automatically_graded' - | 'grading_failed'; - export const AutoStatusEnum = { - NotGraded: 'not_graded' as AutoStatusEnum, - Pending: 'pending' as AutoStatusEnum, - AutomaticallyGraded: 'automatically_graded' as AutoStatusEnum, - GradingFailed: 'grading_failed' as AutoStatusEnum - }; - export type ManualStatusEnum = - | 'not_graded' - | 'manually_graded' - | 'being_edited' - | 'grading_failed'; - export const ManualStatusEnum = { - NotGraded: 'not_graded' as ManualStatusEnum, - ManuallyGraded: 'manually_graded' as ManualStatusEnum, - BeingEdited: 'being_edited' as ManualStatusEnum, - GradingFailed: 'grading_failed' as ManualStatusEnum - }; - export type FeedBackStatusEnum = - | 'not_generated' - | 'generating' - | 'generated' - | 'generation_failed' - | 'feedback_outdated'; - export const FeedBackStatusEnum = { - NotGenerated: 'not_generated' as FeedBackStatusEnum, - Generating: 'generating' as FeedBackStatusEnum, - Generated: 'generated' as FeedBackStatusEnum, - GenerationFailed: 'generation_failed' as FeedBackStatusEnum, - FeedbackOutdated: 'feedback_outdated' as FeedBackStatusEnum - }; + export type AutoStatusEnum = 'not_graded' | 'pending' | 'automatically_graded' | 'grading_failed'; + export const AutoStatusEnum = { + NotGraded: 'not_graded' as AutoStatusEnum, + Pending: 'pending' as AutoStatusEnum, + AutomaticallyGraded: 'automatically_graded' as AutoStatusEnum, + GradingFailed: 'grading_failed' as AutoStatusEnum + }; + export type ManualStatusEnum = 'not_graded' | 'manually_graded' | 'being_edited' | 'grading_failed'; + export const ManualStatusEnum = { + NotGraded: 'not_graded' as ManualStatusEnum, + ManuallyGraded: 'manually_graded' as ManualStatusEnum, + BeingEdited: 'being_edited' as ManualStatusEnum, + GradingFailed: 'grading_failed' as ManualStatusEnum + }; + export type FeedbackStatusEnum = 'not_generated' | 'generating' | 'generated' | 'generation_failed' | 'feedback_outdated'; + export const FeedbackStatusEnum = { + NotGenerated: 'not_generated' as FeedbackStatusEnum, + Generating: 'generating' as FeedbackStatusEnum, + Generated: 'generated' as FeedbackStatusEnum, + GenerationFailed: 'generation_failed' as FeedbackStatusEnum, + FeedbackOutdated: 'feedback_outdated' as FeedbackStatusEnum + }; } + + diff --git a/src/model/submissionPeriod.ts b/src/model/submissionPeriod.ts index 3ab4f11..765d1cc 100644 --- a/src/model/submissionPeriod.ts +++ b/src/model/submissionPeriod.ts @@ -1,16 +1,16 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ -export interface SubmissionPeriod { - period?: string; - scaling?: number; + +export interface SubmissionPeriod { + period?: string; + scaling?: number; } + diff --git a/src/model/user.ts b/src/model/user.ts index 9a6d9a1..950b9fb 100644 --- a/src/model/user.ts +++ b/src/model/user.ts @@ -1,15 +1,15 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech * Do not edit the class manually. */ -export interface User { - name?: string; + +export interface User { + name?: string; } + diff --git a/src/model/userSubmissionsInner.ts b/src/model/userSubmissionsInner.ts index abcaa49..48e2f9e 100644 --- a/src/model/userSubmissionsInner.ts +++ b/src/model/userSubmissionsInner.ts @@ -1,9 +1,7 @@ /** * Grader Extension API Schemas - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 0.1 * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -12,7 +10,9 @@ import { User } from './user'; import { Submission } from './submission'; -export interface UserSubmissionsInner { - user?: User; - submissions?: Array; + +export interface UserSubmissionsInner { + user?: User; + submissions?: Array; } + diff --git a/src/services/assignments.service.ts b/src/services/assignments.service.ts index 2995528..fedd83a 100644 --- a/src/services/assignments.service.ts +++ b/src/services/assignments.service.ts @@ -15,7 +15,7 @@ export function createAssignment( ): Promise { return request( HTTPMethod.POST, - `/lectures/${lectureId}/assignments`, + `/api/lectures/${lectureId}/assignments`, assignment ); } @@ -25,7 +25,8 @@ export function getAllAssignments( reload = false, includeSubmissions = false ): Promise { - let url = `/lectures/${lectureId}/assignments`; + + let url = `/api/lectures/${lectureId}/assignments`; if (includeSubmissions) { const searchParams = new URLSearchParams({ 'include-submissions': String(includeSubmissions) @@ -42,7 +43,7 @@ export function getAssignment( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lectureId}/assignments/${assignmentId}`, + `/api/lectures/${lectureId}/assignments/${assignmentId}`, null, reload ); @@ -55,7 +56,7 @@ export function getAssignmentProperties( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lectureId}/assignments/${assignmentId}/properties`, + `/api/lectures/${lectureId}/assignments/${assignmentId}/properties`, null, reload ); @@ -67,7 +68,7 @@ export function updateAssignment( ): Promise { return request( HTTPMethod.PUT, - `/lectures/${lectureId}/assignments/${assignment.id}`, + `/api/lectures/${lectureId}/assignments/${assignment.id}`, assignment ); } @@ -78,7 +79,7 @@ export function generateAssignment( ): Promise { return request( HTTPMethod.PUT, - `/lectures/${lectureId}/assignments/${assignment.id}/generate`, + `/api/lectures/${lectureId}/assignments/${assignment.id}/generate`, null ); } @@ -90,7 +91,7 @@ export function fetchAssignment( metadataOnly: boolean = false, reload: boolean = false ): Promise { - let url = `/lectures/${lectureId}/assignments/${assignmentId}`; + let url = `/api/lectures/${lectureId}/assignments/${assignmentId}`; if (instructor || metadataOnly) { const searchParams = new URLSearchParams({ 'instructor-version': String(instructor), @@ -108,7 +109,7 @@ export function deleteAssignment( ): Promise { return request( HTTPMethod.DELETE, - `/lectures/${lectureId}/assignments/${assignmentId}`, + `/api/lectures/${lectureId}/assignments/${assignmentId}`, null ); } @@ -117,18 +118,27 @@ export function pushAssignment( lectureId: number, assignmentId: number, repoType: string, - commitMessage?: string + commitMessage?: string, + selectedFiles?: string[] ): Promise { - let url = `/lectures/${lectureId}/assignments/${assignmentId}/push/${repoType}`; - if (commitMessage) { + let url = `/api/lectures/${lectureId}/assignments/${assignmentId}/push/${repoType}`; + if (commitMessage && commitMessage !== undefined) { const searchParams = new URLSearchParams({ 'commit-message': commitMessage }); url += '?' + searchParams; } + + if (selectedFiles && selectedFiles.length > 0) { + selectedFiles.forEach(file => { + url += `&selected-files=${encodeURIComponent(file)}`; + }); + } + return request(HTTPMethod.PUT, url, null); } + export function pullAssignment( lectureId: number, assignmentId: number, @@ -136,7 +146,7 @@ export function pullAssignment( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lectureId}/assignments/${assignmentId}/pull/${repoType}`, + `/api/lectures/${lectureId}/assignments/${assignmentId}/pull/${repoType}`, null ); } @@ -147,7 +157,7 @@ export function resetAssignment( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lecture.id}/assignments/${assignment.id}/reset`, + `/api/lectures/${lecture.id}/assignments/${assignment.id}/reset`, null ); } diff --git a/src/services/file.service.ts b/src/services/file.service.ts index 68a32a9..9c141d2 100644 --- a/src/services/file.service.ts +++ b/src/services/file.service.ts @@ -4,9 +4,8 @@ // This source code is licensed under the BSD-style license found in the // LICENSE file in the root directory of this source tree. -import { FilterFileBrowserModel } from '@jupyterlab/filebrowser/lib/model'; +import { FileBrowserModel } from '@jupyterlab/filebrowser/lib/model'; import { GlobalObjects } from '../index'; -import { renameFile } from '@jupyterlab/docmanager'; import { Contents } from '@jupyterlab/services'; import { Assignment } from '../model/assignment'; import { HTTPMethod, request } from './request.service'; @@ -15,6 +14,7 @@ import { RepoType } from '../components/util/repo-type'; import IModel = Contents.IModel; import { PageConfig, PathExt } from '@jupyterlab/coreutils'; import { enqueueSnackbar } from 'notistack'; +import { RemoteFileStatus } from '../model/remoteFileStatus'; // remove slashes at beginning and end of base path if they exist export let lectureBasePath = PageConfig.getOption('lectures_base_path').replace( @@ -39,19 +39,20 @@ export interface File { content: File[]; } -// TODO: getFiles should return Promise export const getFiles = async (path: string): Promise => { if (path === null) { return []; } - const model = new FilterFileBrowserModel({ - auto: true, - manager: GlobalObjects.docManager + const model = new FileBrowserModel({ + auto: false, + manager: GlobalObjects.docManager, + refreshInterval: 1000000 }); try { await model.cd(path); + await model.refresh(); } catch (_) { return []; } @@ -83,28 +84,29 @@ export const getFiles = async (path: string): Promise => { } f = items.next(); } - - console.log('getting files from path ' + path); return files; }; -export const getRelativePathAssignment = (path: string) => { - const regex = /assignments\/[^/]+\/(.+)/; +export const getRelativePath = (path: string, reg: 'assignments' | 'source') => { + const regexStr = `${reg}\/[^/]+\/(.+)`; + const regex = new RegExp(regexStr); const match = path.match(regex); return match ? match[1] : path; }; -export const extractRelativePathsAssignment = (file: File) => { +export const extractRelativePaths = (file: File, reg: 'assignments' | 'source' ) => { if (file.type === 'directory') { const nestedPaths = file.content.flatMap(nestedFile => - extractRelativePathsAssignment(nestedFile) + extractRelativePaths(nestedFile, reg) ); - return [getRelativePathAssignment(file.path), ...nestedPaths]; + return [getRelativePath(file.path, reg), ...nestedPaths]; } else { - return [getRelativePathAssignment(file.path)]; + return [getRelativePath(file.path, reg)]; } }; + + export const openFile = async (path: string) => { GlobalObjects.commands .execute('docmanager:open', { @@ -123,12 +125,14 @@ export const openFile = async (path: string) => { export const makeDir = async (path: string, name: string) => { const newPath = PathExt.join(path, name); let exists = false; - const model = new FilterFileBrowserModel({ - auto: true, - manager: GlobalObjects.docManager + const model = new FileBrowserModel({ + auto: false, + manager: GlobalObjects.docManager, + refreshInterval: 1000000 }); try { await model.cd(path); + await model.refresh(); } catch (_) { exists = false; } @@ -163,10 +167,8 @@ export const makeDir = async (path: string, name: string) => { export const makeDirs = async (path: string, names: string[]) => { let p = path; - console.log('path: ' + path); for (let i = 0; i < names.length; i++) { const n = names[i]; - console.log('try to create dir: ' + names[i]); p = await makeDir(p, n); } return p; @@ -187,7 +189,7 @@ export function getGitLog( repo: RepoType, nCommits: number ): Promise { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/log/${repo}/`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/log/${repo}/`; const searchParams = new URLSearchParams({ n: String(nCommits) }); @@ -200,7 +202,18 @@ export function getRemoteStatus( assignment: Assignment, repo: RepoType, reload = false -): Promise { - const url = `/lectures/${lecture.id}/assignments/${assignment.id}/remote-status/${repo}/`; - return request(HTTPMethod.GET, url, null, reload); +): Promise { + const url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/remote-status/${repo}/`; + return request(HTTPMethod.GET, url, null, reload); +} + +export function getRemoteFileStatus( + lecture: Lecture, + assignment: Assignment, + repo: RepoType, + filePath: string, + reload = false +): Promise { + const url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/remote-file-status/${repo}/?file=${encodeURIComponent(filePath)}`; + return request(HTTPMethod.GET, url, null, reload); } diff --git a/src/services/grading.service.ts b/src/services/grading.service.ts index 50394e3..50db6a6 100644 --- a/src/services/grading.service.ts +++ b/src/services/grading.service.ts @@ -17,7 +17,7 @@ export function createManualFeedback( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lectid}/assignments/${assignid}/grading/${subid}/manual`, + `/api/lectures/${lectid}/assignments/${assignid}/grading/${subid}/manual`, null ); } @@ -27,7 +27,7 @@ export function saveSubmissions( assignment: Assignment, filter: 'none' | 'latest' | 'best' = 'none' ): Promise { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/submissions/save`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/submissions/save`; if (filter) { const searchParams = new URLSearchParams({ filter: filter @@ -44,7 +44,7 @@ export function autogradeSubmission( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lecture.id}/assignments/${assignment.id}/grading/${submission.id}/auto`, + `/api/lectures/${lecture.id}/assignments/${assignment.id}/grading/${submission.id}/auto`, null ); } @@ -56,7 +56,7 @@ export function generateFeedback( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lecture.id}/assignments/${assignment.id}/grading/${submission.id}/feedback`, + `/api/lectures/${lecture.id}/assignments/${assignment.id}/grading/${submission.id}/feedback`, null ); } @@ -70,7 +70,7 @@ export function getStudentSubmissions( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lecture.id}/assignements/${assignment.id}/grading`, + `/api/lectures/${lecture.id}/assignements/${assignment.id}/grading`, null, reload ); @@ -83,7 +83,7 @@ export function getManualFeedback( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lecture.id}/assignments/${assignment.id}/grading/${student.name}/manual`, + `/api/lectures/${lecture.id}/assignments/${assignment.id}/grading/${student.name}/manual`, null ); } @@ -96,7 +96,7 @@ export function updateManualFeedback( ): Promise { return request( HTTPMethod.PUT, - `/lectures/${lecture.id}/assignements/${assignment.id}/grading/${student.name}/manual`, + `/api/lectures/${lecture.id}/assignements/${assignment.id}/grading/${student.name}/manual`, manual ); } @@ -109,7 +109,7 @@ export function deleteManualFeedback( ): Promise { return request( HTTPMethod.DELETE, - `/lectures/${lecture.id}/assignments/${assignment.id}/grading/${student.name}/manual`, + `/api/lectures/${lecture.id}/assignments/${assignment.id}/grading/${student.name}/manual`, manual ); } @@ -121,7 +121,7 @@ export function getGrade( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lecture.id}/assignments/${assignment.id}/grading/${student.name}/score`, + `/api/lectures/${lecture.id}/assignments/${assignment.id}/grading/${student.name}/score`, null ); } diff --git a/src/services/lectures.service.ts b/src/services/lectures.service.ts index 5f99f35..e97bb8c 100644 --- a/src/services/lectures.service.ts +++ b/src/services/lectures.service.ts @@ -11,7 +11,7 @@ export function getAllLectures( complete: boolean = false, reload = false ): Promise { - let url = '/lectures'; + let url = 'api/lectures'; if (complete) { const searchParams = new URLSearchParams({ complete: String(complete) @@ -24,7 +24,7 @@ export function getAllLectures( export function updateLecture(lecture: Lecture): Promise { return request( HTTPMethod.PUT, - `/lectures/${lecture.id}`, + `/api/lectures/${lecture.id}`, lecture ); } @@ -35,14 +35,14 @@ export function getLecture( ): Promise { return request( HTTPMethod.GET, - `/lectures/${lectureId}`, + `/api/lectures/${lectureId}`, null, reload ); } export function deleteLecture(lectureId: number): Promise { - return request(HTTPMethod.DELETE, `/lectures/${lectureId}`, null); + return request(HTTPMethod.DELETE, `/api/lectures/${lectureId}`, null); } export function getUsers( @@ -53,5 +53,5 @@ export function getUsers( instructors: string[]; tutors: string[]; students: string[]; - }>(HTTPMethod.GET, `/lectures/${lectureId}/users`, null, reload); + }>(HTTPMethod.GET, `/api/lectures/${lectureId}/users`, null, reload); } diff --git a/src/services/permission.service.ts b/src/services/permission.service.ts index 15731a4..67e0cc0 100644 --- a/src/services/permission.service.ts +++ b/src/services/permission.service.ts @@ -27,7 +27,7 @@ export namespace UserPermissions { permissions = {}; const response = await request<{ lecture_code: string; scope: number }[]>( HTTPMethod.GET, - '/permissions', + '/api/permissions', null ); response.forEach(role => { diff --git a/src/services/request.service.ts b/src/services/request.service.ts index f73e154..e22dd0b 100644 --- a/src/services/request.service.ts +++ b/src/services/request.service.ts @@ -6,8 +6,6 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; -import { from, lastValueFrom } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; export enum HTTPMethod { GET = 'GET', @@ -16,6 +14,15 @@ export enum HTTPMethod { DELETE = 'DELETE' } +export class HTTPError extends Error { + statusCode: number; + constructor(statusCode: number, message: string) { + super(`${statusCode} - ${message}`); + this.statusCode = statusCode; + this.name = "HTTPError"; + } +} + export function request( method: HTTPMethod, endPoint: string, @@ -47,20 +54,36 @@ export function request( return ServerConnection.makeRequest(requestUrl, options, settings).then( async response => { + const method = options.method || 'GET'; // assuming `method` is part of options. + + // handle non-OK responses if (!response.ok) { - return response.text().then(text => { - throw new Error(JSON.parse(text)['reason']); - }); + const errorText = await response.text(); + // default error message + let errorMessage = 'Unknown error'; + + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData['reason'] || errorMessage; + } catch (e) { + errorMessage = errorText; // fallback to raw error text if not JSON + } + + // throw custom HTTPError with status code and message + throw new HTTPError(response.status, errorMessage); } + let data: any = await response.text(); + // validate response body if (data.length > 0) { try { data = JSON.parse(data); } catch (error) { - console.log('Not a JSON response body.', response); + console.log('Not a JSON response body, handling as plain text.', response); } } - console.log('Request ' + method.toString() + ' URL: ' + requestUrl); + + console.log(`Request ${method} URL: ${requestUrl}`); console.log(data); return data; } diff --git a/src/services/submissions.service.ts b/src/services/submissions.service.ts index 1046e1f..71b412e 100644 --- a/src/services/submissions.service.ts +++ b/src/services/submissions.service.ts @@ -14,7 +14,7 @@ export function submitAssignment( assignment: Assignment, submit = false ) { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/push/assignment`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/push/assignment`; if (submit) { const searchParams = new URLSearchParams({ submit: String(submit) @@ -31,7 +31,7 @@ export async function pullFeedback( ) { return request( HTTPMethod.GET, - `/lectures/${lecture.id}/assignments/${assignment.id}/grading/${submission.id}/pull/feedback`, + `/api/lectures/${lecture.id}/assignments/${assignment.id}/grading/${submission.id}/pull/feedback`, null ); } @@ -41,7 +41,7 @@ export async function pullSubmissionFiles( assignment: Assignment, submission: Submission ) { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/pull/edit`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/pull/edit`; const searchParams = new URLSearchParams({ subid: String(submission.id) @@ -55,7 +55,7 @@ export async function createSubmissionFiles( assignment: Assignment, username: string ) { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/push/edit`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/push/edit`; const searchParams = new URLSearchParams({ for_user: username }); @@ -68,7 +68,7 @@ export async function pushSubmissionFiles( assignment: Assignment, submission: Submission ) { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/push/edit`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/push/edit`; const searchParams = new URLSearchParams({ subid: String(submission.id) }); @@ -82,7 +82,7 @@ export function getSubmissions( filter = 'none', reload = false ): Promise { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/submissions`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/submissions`; if (filter) { const searchParams = new URLSearchParams({ filter: filter @@ -99,7 +99,7 @@ export function getAllSubmissions( instructor = true, reload = false ): Promise { - let url = `/lectures/${lectureId}/assignments/${assignmentId}/submissions`; + let url = `/api/lectures/${lectureId}/assignments/${assignmentId}/submissions`; if (filter || instructor) { const searchParams = new URLSearchParams({ @@ -111,13 +111,14 @@ export function getAllSubmissions( return request(HTTPMethod.GET, url, null, reload); } + export function getFeedback( lecture: Lecture, assignment: Assignment, latest = false, instructor = false ): Promise { - let url = `/lectures/${lecture.id}/assignments/${assignment.id}/feedback`; + let url = `/api/lectures/${lecture.id}/assignments/${assignment.id}/feedback`; if (latest || instructor) { const searchParams = new URLSearchParams({ 'instructor-version': String(instructor), @@ -134,7 +135,7 @@ export function getProperties( submissionId: number, reload = false ): Promise
Sorry, an unexpected error has occurred.
- {error.statusText || error.message} -
+ navigate(-1)} + > + Back + +