From ea7c45d5f83012c22587ae3076fdbb51bddd211c Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Thu, 22 Feb 2024 19:53:13 +0100 Subject: [PATCH 001/377] updated dependencies of node and python to run on newest version --- .github/workflows/ci-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index c0d4cfa4..8b299c26 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -8,10 +8,10 @@ jobs: - uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ hashFiles('package-lock.json') }} @@ -42,7 +42,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.x' cache: 'pip' From 7dec8520732712ff8b383537d9a7d41f9cf0cf30 Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Fri, 23 Feb 2024 17:55:41 +0100 Subject: [PATCH 002/377] Fix #22 --- backend/project/__init__.py | 4 +- backend/project/endpoints/index.py | 10 ----- .../endpoints/index/OpenAPI_Object.json | 45 +++++++++++++++++++ backend/project/endpoints/index/index.py | 13 ++++++ 4 files changed, 60 insertions(+), 12 deletions(-) delete mode 100644 backend/project/endpoints/index.py create mode 100644 backend/project/endpoints/index/OpenAPI_Object.json create mode 100644 backend/project/endpoints/index/index.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 98017b80..23a7ee90 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -2,8 +2,8 @@ This file is the base of the Flask API. It contains the basic structure of the API. """ -from flask import Flask, jsonify -from .endpoints.index import index_bp +from flask import Flask +from .endpoints.index.index import index_bp def create_app(): """ diff --git a/backend/project/endpoints/index.py b/backend/project/endpoints/index.py deleted file mode 100644 index b5536eaf..00000000 --- a/backend/project/endpoints/index.py +++ /dev/null @@ -1,10 +0,0 @@ -from flask import Blueprint -from flask_restful import Resource - -index_bp = Blueprint("index", __name__) - -class Index(Resource): - def get(self): - return {"Message": "Hello World!"} - -index_bp.add_url_rule("/", view_func=Index.as_view("index")) \ No newline at end of file diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json new file mode 100644 index 00000000..7243ff59 --- /dev/null +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -0,0 +1,45 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Pigeonhole API", + "summary": "A project submission and grading API for University Ghent students and professors.", + "description": "The API built for the Pigeonhole application. It serves as an interface for student of University Ghent. They can submit solutions to projects created by their professors. Professors and their assistents can then review these submitions, grade them and define custom tests that automatically run on every submition. The API is built using the OpenAPI 3.1.0 specification.", + "version": "1.0.0", + "contact": { + "name": "Project discussion forum", + "url": "https://github.com/SELab-2/UGent-opgave/discussions", + "email": "Bart.Coppens@UGent.be" + }, + "x-authors": [ + { + "name": "Aron Buzogany", + "github": "https://github.com/AronBuzogany" + }, + { + "name": "Gerwoud Van den Eynden", + "github": "https://github.com/Gerwoud" + }, + { + "name": "Jarne Clauw", + "github": "https://github.com/JarneClauw" + }, + { + "name": "Siebe Vlietinck", + "github": "https://github.com/Vucis" + }, + { + "name": "Warre Provoost", + "github": "https://github.com/warreprovoost" + }, + { + "name": "Cedric Mekeirle", + "github": "https://github.com/JibrilExe" + }, + { + "name": "Matisse Sulzer", + "github": "https://github.com/Matisse-Sulzer" + } + ] + }, + "paths": [] +} diff --git a/backend/project/endpoints/index/index.py b/backend/project/endpoints/index/index.py new file mode 100644 index 00000000..5d7a3bb1 --- /dev/null +++ b/backend/project/endpoints/index/index.py @@ -0,0 +1,13 @@ +from flask import Blueprint, send_from_directory +from flask_restful import Resource, Api +import os + +index_bp = Blueprint("index", __name__) +index_endpoint = Api(index_bp) + +class Index(Resource): + def get(self): + dir_path = os.path.dirname(os.path.realpath(__file__)) + return send_from_directory(dir_path, "OpenAPI_Object.json") + +index_bp.add_url_rule("/", view_func=Index.as_view("index")) \ No newline at end of file From 543d961a40ddde974034edd69eab6ca8d65f846c Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Fri, 23 Feb 2024 18:52:42 +0100 Subject: [PATCH 003/377] Backend/feature/db models (#19) * Basic db definition in flask * main.py * Defined official db model in SQLalchemy * db model definitions in sqlalchemy * flask_sqlalchemy in requirements * foutje * fixed db_uri * db initialization inside create_app * db_uri and code clean * import order fix * database uri added * .env added to ignore * Updated docs and .gitignore * A first succesfull test for user model * Doc cleanup and test function for courses and course_relations models added * Project and submission test added * added psycopg to dependencies * dockerized tests to host postgres server * created test script * created test directory to test models * waiting for postgres service to start before running test scripts and moved env variables * changed github action to run test script instead * constructing pytests for models * running test script with sudo * adding bash to run script * fixing pytest * pytests fixed, 1 warning left * warning fix * fixed: run github action job on self-hosted runner * fixed: no longer running test script with privileges * added: installing docker-compose to run our backend tests * fixed: no longer running compose install with permissions * using ubuntu-latest runner until docker-compose is installed on our self-hosted runner --------- Co-authored-by: warre Co-authored-by: Aron Buzogany Co-authored-by: abuzogan --- .github/workflows/ci-tests.yml | 6 +- backend/.gitignore | 3 +- backend/Dockerfile.test | 15 +++ backend/project/__init__.py | 21 +++- backend/project/__main__.py | 7 +- backend/project/endpoints/index.py | 11 +- backend/project/models/__init__.py | 0 backend/project/models/course_relations.py | 31 ++++++ backend/project/models/courses.py | 15 +++ backend/project/models/projects.py | 28 +++++ backend/project/models/submissions.py | 25 +++++ backend/project/models/users.py | 16 +++ backend/requirements.txt | 3 + backend/run_tests.sh | 20 ++++ backend/tests.yaml | 31 ++++++ backend/tests/models/conftest.py | 104 ++++++++++++++++++ backend/tests/models/course_test.py | 96 ++++++++++++++++ .../models/projects_and_submissions_test.py | 58 ++++++++++ backend/tests/models/users_test.py | 19 ++++ 19 files changed, 499 insertions(+), 10 deletions(-) create mode 100644 backend/Dockerfile.test create mode 100644 backend/project/models/__init__.py create mode 100644 backend/project/models/course_relations.py create mode 100644 backend/project/models/courses.py create mode 100644 backend/project/models/projects.py create mode 100644 backend/project/models/submissions.py create mode 100644 backend/project/models/users.py create mode 100644 backend/run_tests.sh create mode 100644 backend/tests.yaml create mode 100644 backend/tests/models/conftest.py create mode 100644 backend/tests/models/course_test.py create mode 100644 backend/tests/models/projects_and_submissions_test.py create mode 100644 backend/tests/models/users_test.py diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index c0d4cfa4..2231c368 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -37,7 +37,7 @@ jobs: working-directory: ./frontend run: npm run lint Backend-tests: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -50,10 +50,10 @@ jobs: - name: Install dependencies working-directory: ./backend run: pip3 install -r requirements.txt && pip3 install -r dev-requirements.txt - + - name: Running tests working-directory: ./backend - run: pytest + run: bash ./run_tests.sh - name: Run linting working-directory: ./backend diff --git a/backend/.gitignore b/backend/.gitignore index c5c0cc3f..25343357 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -7,4 +7,5 @@ __pycache__/ htmlcov/ docs/_build/ dist/ -venv/ \ No newline at end of file +venv/ +.env diff --git a/backend/Dockerfile.test b/backend/Dockerfile.test new file mode 100644 index 00000000..f975133c --- /dev/null +++ b/backend/Dockerfile.test @@ -0,0 +1,15 @@ +FROM python:3.9-slim + +# Set the working directory +WORKDIR /app + +# Copy the application code into the container +COPY . /app + +# Install dependencies +RUN apt-get update +RUN apt-get install -y --no-install-recommends python3-pip +RUN pip3 install --no-cache-dir -r requirements.txt -r dev-requirements.txt + +# Command to run the tests +CMD ["pytest"] \ No newline at end of file diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 98017b80..66435003 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -2,17 +2,34 @@ This file is the base of the Flask API. It contains the basic structure of the API. """ -from flask import Flask, jsonify +from flask import Flask +from flask_sqlalchemy import SQLAlchemy from .endpoints.index import index_bp +db = SQLAlchemy() + def create_app(): """ Create a Flask application instance. Returns: Flask -- A Flask application instance """ - app = Flask(__name__) + app = Flask(__name__) app.register_blueprint(index_bp) return app + +def create_app_with_db(db_uri:str): + """ + Initialize the database with the given uri + and connect it to the app made with create_app. + Parameters: + db_uri (str): The URI of the database to initialize. + Returns: + Flask -- A Flask application instance + """ + app = create_app() + app.config["SQLALCHEMY_DATABASE_URI"] = db_uri + db.init_app(app) + return app diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 0e79612a..9448b0eb 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,9 +1,12 @@ """Main entry point for the application.""" from sys import path +from os import getenv +from dotenv import load_dotenv +from project import create_app_with_db path.append(".") if __name__ == "__main__": - from project import create_app - app = create_app() + load_dotenv() + app = create_app_with_db(getenv("DB_HOST")) app.run(debug=True) diff --git a/backend/project/endpoints/index.py b/backend/project/endpoints/index.py index b5536eaf..e5b68473 100644 --- a/backend/project/endpoints/index.py +++ b/backend/project/endpoints/index.py @@ -1,10 +1,17 @@ +"""Index api point""" from flask import Blueprint from flask_restful import Resource index_bp = Blueprint("index", __name__) + class Index(Resource): + """Api endpoint for the / route""" + def get(self): + """Example of an api endpoint function that will respond to get requests made to / + return a json data structure with key Message and value Hello World!""" return {"Message": "Hello World!"} - -index_bp.add_url_rule("/", view_func=Index.as_view("index")) \ No newline at end of file + + +index_bp.add_url_rule("/", view_func=Index.as_view("index")) diff --git a/backend/project/models/__init__.py b/backend/project/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/project/models/course_relations.py b/backend/project/models/course_relations.py new file mode 100644 index 00000000..0677ef54 --- /dev/null +++ b/backend/project/models/course_relations.py @@ -0,0 +1,31 @@ +"""Models for relation between users and courses""" +# pylint: disable=too-few-public-methods + +from sqlalchemy import Integer, Column, ForeignKey, PrimaryKeyConstraint, String +from project import db +from project.models.users import Users +from project.models.courses import Courses + +class BaseCourseRelation(db.Model): + """Base class for course relation models, + both course relation tables have a + course_id of the course to wich someone is related and + an uid of the related person""" + + __abstract__ = True + + course_id = Column(Integer, ForeignKey('courses.course_id'), nullable=False) + uid = Column(String(255), ForeignKey("users.uid"), nullable=False) + __table_args__ = ( + PrimaryKeyConstraint("course_id", "uid"), + ) + +class CourseAdmins(BaseCourseRelation): + """Admin to course relation model""" + + __tablename__ = "course_admins" + +class CourseStudents(BaseCourseRelation): + """Student to course relation model""" + + __tablename__ = "course_students" diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py new file mode 100644 index 00000000..0881ca56 --- /dev/null +++ b/backend/project/models/courses.py @@ -0,0 +1,15 @@ +"""The Courses model""" +# pylint: disable=too-few-public-methods +from sqlalchemy import Integer, Column, ForeignKey, String +from project import db +from project.models.users import Users + +class Courses(db.Model): + """This class described the courses table, + a course has an id, name, optional ufora id and the teacher that created it""" + + __tablename__ = "courses" + course_id = Column(Integer, primary_key=True) + name = Column(String(50), nullable=False) + ufora_id = Column(String(50), nullable=True) + teacher = Column(String(255), ForeignKey("users.uid"), nullable=False) diff --git a/backend/project/models/projects.py b/backend/project/models/projects.py new file mode 100644 index 00000000..a64f6082 --- /dev/null +++ b/backend/project/models/projects.py @@ -0,0 +1,28 @@ +"""Model for projects""" +# pylint: disable=too-few-public-methods +from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from project import db +from project.models.courses import Courses + +class Projects(db.Model): + """This class describes the projects table, + a projects has an id, a title, a description, + an optional assignment file that can contain more explanation of the projects, + an optional deadline, + the course id of the course to which the project belongs, + visible for students variable so a teacher can decide if the students can see it yet, + archieved var so we can implement the archiving functionality, + a test path,script name and regex experssions for automated testing""" + + __tablename__ = "projects" + project_id = Column(Integer, primary_key=True) + title = Column(String(50), nullable=False, unique=False) + descriptions = Column(Text, nullable=False) + assignment_file = Column(String(50)) + deadline = Column(DateTime(timezone=True)) + course_id = Column(Integer, ForeignKey("courses.course_id"), nullable=False) + visible_for_students = Column(Boolean, nullable=False) + archieved = Column(Boolean, nullable=False) + test_path = Column(String(50)) + script_name = Column(String(50)) + regex_expressions = Column(ARRAY(String(50))) diff --git a/backend/project/models/submissions.py b/backend/project/models/submissions.py new file mode 100644 index 00000000..f9de28b4 --- /dev/null +++ b/backend/project/models/submissions.py @@ -0,0 +1,25 @@ +"""Model for submissions""" +# pylint: disable=too-few-public-methods +from sqlalchemy import Column,String,ForeignKey,Integer,CheckConstraint,DateTime,Boolean +from project import db +from project.models.users import Users + +class Submissions(db.Model): + """This class describes the submissions table, + submissions can be made to a project, a submission has + and id, a uid from the user that uploaded it, + the project id of the related project, + an optional grading, + the submission time, + submission path, + and finally the submission status + so we can easily present in a list which submission succeeded the automated checks""" + + __tablename__ = "submissions" + submission_id = Column(Integer, nullable=False, primary_key=True) + uid = Column(String(255), ForeignKey("users.uid"), nullable=False) + project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False) + grading = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) + submission_time = Column(DateTime(timezone=True), nullable=False) + submission_path = Column(String(50), nullable=False) + submission_status = Column(Boolean, nullable=False) diff --git a/backend/project/models/users.py b/backend/project/models/users.py new file mode 100644 index 00000000..c5af5287 --- /dev/null +++ b/backend/project/models/users.py @@ -0,0 +1,16 @@ +"""Model for users""" +# pylint: disable=too-few-public-methods +from sqlalchemy import Boolean, Column, String +from project import db + + +class Users(db.Model): + """This class defines the users table, + a user has an uid, + is_teacher and is_admin booleans because a user + can be either a student,admin or teacher""" + + __tablename__ = "users" + uid = Column(String(255), primary_key=True) + is_teacher = Column(Boolean) + is_admin = Column(Boolean) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9687a048..943fb75a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,2 +1,5 @@ flask flask-restful +flask-sqlalchemy +python-dotenv +psycopg2-binary \ No newline at end of file diff --git a/backend/run_tests.sh b/backend/run_tests.sh new file mode 100644 index 00000000..a35d5cb2 --- /dev/null +++ b/backend/run_tests.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Run Docker Compose to build and start the services, and capture the exit code from the test runner service +docker-compose -f tests.yaml up --build --exit-code-from test-runner + +# Store the exit code in a variable +exit_code=$? + +# After the tests are finished, stop and remove the containers +docker-compose -f tests.yaml down + +# Check the exit code to determine whether the tests passed or failed +if [ $exit_code -eq 0 ]; then + echo "Tests passed!" +else + echo "Tests failed!" +fi + +# Exit with the same exit code as the test runner service +exit $exit_code diff --git a/backend/tests.yaml b/backend/tests.yaml new file mode 100644 index 00000000..43e401c9 --- /dev/null +++ b/backend/tests.yaml @@ -0,0 +1,31 @@ +version: '3.8' + +services: + postgres: + image: postgres:latest + environment: + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: test_database + healthcheck: + test: ["CMD-SHELL", "pg_isready -U test_user -d test_database"] + interval: 5s + timeout: 3s + retries: 3 + start_period: 5s + + test-runner: + build: + context: . + dockerfile: Dockerfile.test + depends_on: + postgres: + condition: service_healthy + environment: + POSTGRES_HOST: postgres # Use the service name defined in Docker Compose + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + POSTGRES_DB: test_database + volumes: + - .:/app + command: ["pytest"] diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py new file mode 100644 index 00000000..64e4461c --- /dev/null +++ b/backend/tests/models/conftest.py @@ -0,0 +1,104 @@ +""" + +""" +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from datetime import datetime +from project import db +from project.models.courses import Courses +from project.models.course_relations import CourseAdmins, CourseStudents +from project.models.projects import Projects +from project.models.submissions import Submissions +from project.models.users import Users +from sqlalchemy.engine.url import URL +from dotenv import load_dotenv +import pytest + +load_dotenv() + +DATABSE_NAME = os.getenv('POSTGRES_DB') +DATABASE_USER = os.getenv('POSTGRES_USER') +DATABASE_PASSWORD = os.getenv('POSTGRES_PASSWORD') +DATABASE_HOST = os.getenv('POSTGRES_HOST') + +url = URL.create( + drivername="postgresql", + username=DATABASE_USER, + host=DATABASE_HOST, + database=DATABSE_NAME, + password=DATABASE_PASSWORD +) + +engine = create_engine(url) +Session = sessionmaker(bind=engine) + +@pytest.fixture +def db_session(): + db.metadata.create_all(engine) + session = Session() + yield session + session.rollback() + session.close() + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() + +@pytest.fixture +def valid_user(): + user = Users(uid="student", is_teacher=False, is_admin=False) + return user + +@pytest.fixture +def teachers(): + users = [Users(uid=str(i), is_teacher=True, is_admin=False) for i in range(10)] + return users + +@pytest.fixture +def course_teacher(): + sel2_teacher = Users(uid="Bart", is_teacher=True, is_admin=False) + return sel2_teacher + +@pytest.fixture +def course(course_teacher): + sel2 = Courses(name="Sel2", teacher=course_teacher.uid) + return sel2 + +@pytest.fixture +def course_students(): + students = [ + Users(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) + for i in range(5) + ] + return students + +@pytest.fixture +def course_students_relation(course,course_students): + course_relations = [ + CourseStudents(course_id=course.course_id, uid=course_students[i].uid) + for i in range(5) + ] + return course_relations + +@pytest.fixture +def assistent(): + assist = Users(uid="assistent_sel2") + return assist + +@pytest.fixture() +def course_admin(course,assistent): + admin_relation = CourseAdmins(uid=assistent.uid, course_id=course.course_id) + return admin_relation + +@pytest.fixture() +def valid_project(course): + deadline = datetime(2024, 2, 25, 12, 0, 0) # February 25, 2024, 12:00 PM + project = Projects( + title="Project", + descriptions="Test project", + deadline=deadline, + visible_for_students=True, + archieved=False, + ) + return project diff --git a/backend/tests/models/course_test.py b/backend/tests/models/course_test.py new file mode 100644 index 00000000..d8b92e1a --- /dev/null +++ b/backend/tests/models/course_test.py @@ -0,0 +1,96 @@ +import pytest +from sqlalchemy.exc import IntegrityError +from psycopg2.errors import ForeignKeyViolation +from project.models.courses import Courses +from project.models.users import Users +from project.models.course_relations import CourseAdmins, CourseStudents + + +class TestCoursesModel: + """Test class for the database models""" + + def test_foreignkey_courses_teacher(self, db_session, course: Courses): + """Tests the foreign key relation between courses and the teacher uid""" + with pytest.raises( + IntegrityError + ): + db_session.add(course) + db_session.commit() + + def test_correct_course(self, db_session, course: Courses, course_teacher: Users): + """Tests wether added course and a teacher are correctly connected""" + db_session.add(course_teacher) + db_session.commit() + + db_session.add(course) + db_session.commit() + assert ( + db_session.query(Courses).filter_by(name=course.name).first().teacher + == course_teacher.uid + ) + + def test_foreignkey_coursestudents_uid( + self, db_session, course, course_teacher, course_students_relation + ): + """Test the foreign key of the CourseStudent related to the student uid""" + db_session.add(course_teacher) + db_session.commit() + + db_session.add(course) + db_session.commit() + for s in course_students_relation: + s.course_id = course.course_id + + with pytest.raises( + IntegrityError + ): + db_session.add_all(course_students_relation) + db_session.commit() + + def test_correct_courserelations( + self, + db_session, + course, + course_teacher, + course_students, + course_students_relation, + assistent, + course_admin, + ): + """Tests if we get the expected results for correct usage of CourseStudents and CourseAdmins""" + db_session.add(course_teacher) + db_session.commit() + + db_session.add(course) + db_session.commit() + + db_session.add_all(course_students) + db_session.commit() + + for s in course_students_relation: + s.course_id = course.course_id + db_session.add_all(course_students_relation) + db_session.commit() + + student_check = [ + s.uid + for s in db_session.query(CourseStudents) + .filter_by(course_id=course.course_id) + .all() + ] + student_uids = [s.uid for s in course_students] + assert student_check == student_uids + + db_session.add(assistent) + db_session.commit() + course_admin.course_id = course.course_id + db_session.add(course_admin) + db_session.commit() + + assert ( + db_session.query(CourseAdmins) + .filter_by(course_id=course.course_id) + .first() + .uid + == assistent.uid + ) diff --git a/backend/tests/models/projects_and_submissions_test.py b/backend/tests/models/projects_and_submissions_test.py new file mode 100644 index 00000000..e79eaf93 --- /dev/null +++ b/backend/tests/models/projects_and_submissions_test.py @@ -0,0 +1,58 @@ +from datetime import datetime +import pytest +from sqlalchemy.exc import IntegrityError +from project.models.courses import Courses +from project.models.course_relations import CourseAdmins, CourseStudents +from project.models.projects import Projects +from project.models.submissions import Submissions +from project.models.users import Users + +class TestProjectsAndSubmissionsModel: + def test_deadline(self,db_session,course,course_teacher,valid_project,valid_user): + db_session.add(course_teacher) + db_session.commit() + db_session.add(course) + db_session.commit() + valid_project.course_id = course.course_id + db_session.add(valid_project) + db_session.commit() + check_project = ( + db_session.query(Projects).filter_by(title=valid_project.title).first() + ) + assert check_project.deadline == valid_project.deadline + + db_session.add(valid_user) + db_session.commit() + submission = Submissions( + uid=valid_user.uid, + project_id=check_project.project_id, + submission_time=datetime.now(), + submission_path="/test/submission/", + submission_status=False, + ) + db_session.add(submission) + db_session.commit() + + submission_check = ( + db_session.query(Submissions) + .filter_by(project_id=check_project.project_id) + .first() + ) + assert submission_check.uid == valid_user.uid + + with pytest.raises( + IntegrityError + ): + submission_check.grading = 100 + db_session.commit() + db_session.rollback() + submission_check.grading = 15 + db_session.commit() + submission_check = ( + db_session.query(Submissions) + .filter_by(project_id=check_project.project_id) + .first() + ) + assert submission_check.grading == 15 + assert submission.grading == 15 + # Interesting! all the model objects are connected \ No newline at end of file diff --git a/backend/tests/models/users_test.py b/backend/tests/models/users_test.py new file mode 100644 index 00000000..e9e1acaf --- /dev/null +++ b/backend/tests/models/users_test.py @@ -0,0 +1,19 @@ +from project.models.users import Users + + +class TestUserModel: + """Test class for the database models""" + + def test_valid_user(self, db_session, valid_user): + db_session.add(valid_user) + db_session.commit() + assert valid_user in db_session.query(Users).all() + + def test_is_teacher(self, db_session, teachers): + db_session.add_all(teachers) + db_session.commit() + teacher_count = 0 + for usr in db_session.query(Users).filter_by(is_teacher=True): + teacher_count += 1 + assert usr.is_teacher + assert teacher_count == 10 \ No newline at end of file From b7a9d444605b28dd82a4e24c5160099313ee99ef Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Fri, 23 Feb 2024 20:07:19 +0100 Subject: [PATCH 004/377] moved endpoint tests to seperate folder --- backend/tests/{ => endpoints}/__init__.py | 0 backend/tests/{ => endpoints}/conftest.py | 0 backend/tests/{test_base.py => endpoints/index_test.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename backend/tests/{ => endpoints}/__init__.py (100%) rename backend/tests/{ => endpoints}/conftest.py (100%) rename backend/tests/{test_base.py => endpoints/index_test.py} (100%) diff --git a/backend/tests/__init__.py b/backend/tests/endpoints/__init__.py similarity index 100% rename from backend/tests/__init__.py rename to backend/tests/endpoints/__init__.py diff --git a/backend/tests/conftest.py b/backend/tests/endpoints/conftest.py similarity index 100% rename from backend/tests/conftest.py rename to backend/tests/endpoints/conftest.py diff --git a/backend/tests/test_base.py b/backend/tests/endpoints/index_test.py similarity index 100% rename from backend/tests/test_base.py rename to backend/tests/endpoints/index_test.py From cb62692d62508db055b4ba46fe46c88b19ed3a37 Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Fri, 23 Feb 2024 20:08:05 +0100 Subject: [PATCH 005/377] added test to test if required field of openapi root object are present --- backend/tests/endpoints/index_test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/tests/endpoints/index_test.py b/backend/tests/endpoints/index_test.py index 803b8456..e432de9f 100644 --- a/backend/tests/endpoints/index_test.py +++ b/backend/tests/endpoints/index_test.py @@ -4,3 +4,10 @@ def test_home(client): """Test whether the index page is accesible""" response = client.get("/") assert response.status_code == 200 + +def test_openapi_spec(client): + "Test whether the required fields of the openapi spec are present" + response = client.get("/") + response_json = response.json + assert response_json["openapi"] is not None + assert response_json["info"] is not None \ No newline at end of file From 922fa0f62d487e3dcaf0b2d8ccf9a1ede7655c36 Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Fri, 23 Feb 2024 20:18:24 +0100 Subject: [PATCH 006/377] added init file to support multi level module referencing --- backend/tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/tests/__init__.py diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..e69de29b From 4991b3f7110ff24414b4481d86dc8ec0b0871ee4 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 24 Feb 2024 10:49:13 +0100 Subject: [PATCH 007/377] Workflows/backend/linting isnt applied recursively (#25) * Fixes #24 * linting: added docs and removed trailing whitespaces * fixed: linting * added lint configuration file * added special linting rules where necessary --- .github/workflows/ci-tests.yml | 2 +- backend/project/endpoints/index.py | 16 ++++++---- backend/project/models/course_relations.py | 3 -- backend/project/models/courses.py | 3 +- backend/project/models/projects.py | 3 +- backend/project/models/submissions.py | 3 +- backend/project/models/users.py | 2 +- backend/pylintrc | 10 +++++++ backend/tests/conftest.py | 4 +-- backend/tests/models/__index__.py | 0 backend/tests/models/conftest.py | 29 +++++++++++++------ backend/tests/models/course_test.py | 8 +++-- .../models/projects_and_submissions_test.py | 17 +++++++---- backend/tests/models/users_test.py | 8 ++++- 14 files changed, 71 insertions(+), 37 deletions(-) create mode 100644 backend/pylintrc create mode 100644 backend/tests/models/__index__.py diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index aa79f382..10a23a0f 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -57,6 +57,6 @@ jobs: - name: Run linting working-directory: ./backend - run: pylint ./*/*.py + run: find . -type f -name "*.py" | xargs pylint diff --git a/backend/project/endpoints/index.py b/backend/project/endpoints/index.py index e5b68473..384cefd9 100644 --- a/backend/project/endpoints/index.py +++ b/backend/project/endpoints/index.py @@ -1,4 +1,7 @@ -"""Index api point""" +""" +This is the index endpoint file. It contains the index endpoint of the API as specified by OpenAPI. +""" + from flask import Blueprint from flask_restful import Resource @@ -6,12 +9,15 @@ class Index(Resource): - """Api endpoint for the / route""" + """ + Subclass of restfull Resource, used to define the index endpoint of the API. + """ def get(self): - """Example of an api endpoint function that will respond to get requests made to / - return a json data structure with key Message and value Hello World!""" - return {"Message": "Hello World!"} + """ + Implementation of the GET method for the index endpoint. Returns the OpenAPI object. + """ + return {"Message": "Hello World!"} index_bp.add_url_rule("/", view_func=Index.as_view("index")) diff --git a/backend/project/models/course_relations.py b/backend/project/models/course_relations.py index 0677ef54..c53807b6 100644 --- a/backend/project/models/course_relations.py +++ b/backend/project/models/course_relations.py @@ -1,10 +1,7 @@ """Models for relation between users and courses""" -# pylint: disable=too-few-public-methods from sqlalchemy import Integer, Column, ForeignKey, PrimaryKeyConstraint, String from project import db -from project.models.users import Users -from project.models.courses import Courses class BaseCourseRelation(db.Model): """Base class for course relation models, diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index 0881ca56..2b208b4c 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,8 +1,7 @@ """The Courses model""" -# pylint: disable=too-few-public-methods + from sqlalchemy import Integer, Column, ForeignKey, String from project import db -from project.models.users import Users class Courses(db.Model): """This class described the courses table, diff --git a/backend/project/models/projects.py b/backend/project/models/projects.py index a64f6082..0dd37911 100644 --- a/backend/project/models/projects.py +++ b/backend/project/models/projects.py @@ -1,8 +1,7 @@ """Model for projects""" -# pylint: disable=too-few-public-methods + from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from project import db -from project.models.courses import Courses class Projects(db.Model): """This class describes the projects table, diff --git a/backend/project/models/submissions.py b/backend/project/models/submissions.py index f9de28b4..97e8762c 100644 --- a/backend/project/models/submissions.py +++ b/backend/project/models/submissions.py @@ -1,8 +1,7 @@ """Model for submissions""" -# pylint: disable=too-few-public-methods + from sqlalchemy import Column,String,ForeignKey,Integer,CheckConstraint,DateTime,Boolean from project import db -from project.models.users import Users class Submissions(db.Model): """This class describes the submissions table, diff --git a/backend/project/models/users.py b/backend/project/models/users.py index c5af5287..c3ea45c0 100644 --- a/backend/project/models/users.py +++ b/backend/project/models/users.py @@ -1,5 +1,5 @@ """Model for users""" -# pylint: disable=too-few-public-methods + from sqlalchemy import Boolean, Column, String from project import db diff --git a/backend/pylintrc b/backend/pylintrc new file mode 100644 index 00000000..83eff274 --- /dev/null +++ b/backend/pylintrc @@ -0,0 +1,10 @@ +[MASTER] +init-hook='import sys; sys.path.append(".")' + +[test-files:*_test.py] +disable= + W0621, # Redefining name %r from outer scope (line %s) + +[modules:project/modules/*] +disable= + R0903 # Too few public methods (modules don't require us to have public methods) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 582991ed..010ef293 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -8,11 +8,11 @@ def app(): Returns: Flask -- A Flask application instance """ - app = create_app() # pylint: disable=redefined-outer-name ; fixture testing requires the same name to be used + app = create_app() yield app @pytest.fixture -def client(app): # pylint: disable=redefined-outer-name ; fixture testing requires the same name to be used +def client(app): """A fixture that creates a test client for the app. Arguments: app {Flask} -- A Flask application instance diff --git a/backend/tests/models/__index__.py b/backend/tests/models/__index__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 64e4461c..150d433e 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -1,19 +1,19 @@ """ - +Configuration for the models tests. Contains all the fixtures needed for multiple models tests. """ + import os +from datetime import datetime from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from datetime import datetime +from sqlalchemy.engine.url import URL +from dotenv import load_dotenv +import pytest from project import db from project.models.courses import Courses from project.models.course_relations import CourseAdmins, CourseStudents from project.models.projects import Projects -from project.models.submissions import Submissions from project.models.users import Users -from sqlalchemy.engine.url import URL -from dotenv import load_dotenv -import pytest load_dotenv() @@ -35,6 +35,8 @@ @pytest.fixture def db_session(): + """Create a new database session for a test. + After the test, all changes are rolled back and the session is closed.""" db.metadata.create_all(engine) session = Session() yield session @@ -47,26 +49,31 @@ def db_session(): @pytest.fixture def valid_user(): + """A valid user for testing""" user = Users(uid="student", is_teacher=False, is_admin=False) - return user + return user @pytest.fixture def teachers(): + """A list of 10 teachers for testing""" users = [Users(uid=str(i), is_teacher=True, is_admin=False) for i in range(10)] return users @pytest.fixture def course_teacher(): + """A user that's a teacher for for testing""" sel2_teacher = Users(uid="Bart", is_teacher=True, is_admin=False) return sel2_teacher - + @pytest.fixture def course(course_teacher): + """A course for testing, with the course teacher as the teacher.""" sel2 = Courses(name="Sel2", teacher=course_teacher.uid) return sel2 @pytest.fixture def course_students(): + """A list of 5 students for testing.""" students = [ Users(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) for i in range(5) @@ -75,6 +82,7 @@ def course_students(): @pytest.fixture def course_students_relation(course,course_students): + """A list of 5 course relations for testing.""" course_relations = [ CourseStudents(course_id=course.course_id, uid=course_students[i].uid) for i in range(5) @@ -83,16 +91,19 @@ def course_students_relation(course,course_students): @pytest.fixture def assistent(): + """An assistent for testing.""" assist = Users(uid="assistent_sel2") return assist @pytest.fixture() def course_admin(course,assistent): + """A course admin for testing.""" admin_relation = CourseAdmins(uid=assistent.uid, course_id=course.course_id) return admin_relation @pytest.fixture() -def valid_project(course): +def valid_project(): + """A valid project for testing.""" deadline = datetime(2024, 2, 25, 12, 0, 0) # February 25, 2024, 12:00 PM project = Projects( title="Project", diff --git a/backend/tests/models/course_test.py b/backend/tests/models/course_test.py index d8b92e1a..20898db8 100644 --- a/backend/tests/models/course_test.py +++ b/backend/tests/models/course_test.py @@ -1,6 +1,6 @@ +"""Test module for the Courses model""" import pytest from sqlalchemy.exc import IntegrityError -from psycopg2.errors import ForeignKeyViolation from project.models.courses import Courses from project.models.users import Users from project.models.course_relations import CourseAdmins, CourseStudents @@ -47,7 +47,7 @@ def test_foreignkey_coursestudents_uid( db_session.add_all(course_students_relation) db_session.commit() - def test_correct_courserelations( + def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arguments are needed for the test self, db_session, course, @@ -57,7 +57,9 @@ def test_correct_courserelations( assistent, course_admin, ): - """Tests if we get the expected results for correct usage of CourseStudents and CourseAdmins""" + """Tests if we get the expected results for + correct usage of CourseStudents and CourseAdmins""" + db_session.add(course_teacher) db_session.commit() diff --git a/backend/tests/models/projects_and_submissions_test.py b/backend/tests/models/projects_and_submissions_test.py index e79eaf93..5f20aa1c 100644 --- a/backend/tests/models/projects_and_submissions_test.py +++ b/backend/tests/models/projects_and_submissions_test.py @@ -1,14 +1,19 @@ +"""This module tests the Projects and Submissions model""" from datetime import datetime import pytest from sqlalchemy.exc import IntegrityError -from project.models.courses import Courses -from project.models.course_relations import CourseAdmins, CourseStudents from project.models.projects import Projects from project.models.submissions import Submissions -from project.models.users import Users -class TestProjectsAndSubmissionsModel: - def test_deadline(self,db_session,course,course_teacher,valid_project,valid_user): +class TestProjectsAndSubmissionsModel: # pylint: disable=too-few-public-methods + """Test class for the database models of projects and submissions""" + def test_deadline(self,db_session, # pylint: disable=too-many-arguments ; all arguments are needed for the test + course, + course_teacher, + valid_project, + valid_user): + """Tests if the deadline is correctly set + and if the submission is correctly connected to the project""" db_session.add(course_teacher) db_session.commit() db_session.add(course) @@ -55,4 +60,4 @@ def test_deadline(self,db_session,course,course_teacher,valid_project,valid_user ) assert submission_check.grading == 15 assert submission.grading == 15 - # Interesting! all the model objects are connected \ No newline at end of file + # Interesting! all the model objects are connected diff --git a/backend/tests/models/users_test.py b/backend/tests/models/users_test.py index e9e1acaf..efc7f579 100644 --- a/backend/tests/models/users_test.py +++ b/backend/tests/models/users_test.py @@ -1,3 +1,6 @@ +""" +This file contains the tests for the Users model. +""" from project.models.users import Users @@ -5,15 +8,18 @@ class TestUserModel: """Test class for the database models""" def test_valid_user(self, db_session, valid_user): + """Tests if a valid user can be added to the database.""" db_session.add(valid_user) db_session.commit() assert valid_user in db_session.query(Users).all() def test_is_teacher(self, db_session, teachers): + """Tests if the is_teacher field is correctly set to True + for the teachers when added to the database.""" db_session.add_all(teachers) db_session.commit() teacher_count = 0 for usr in db_session.query(Users).filter_by(is_teacher=True): teacher_count += 1 assert usr.is_teacher - assert teacher_count == 10 \ No newline at end of file + assert teacher_count == 10 From b915654e9744444c8c871e751f486d48f1d2ea33 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 10:56:49 +0100 Subject: [PATCH 008/377] #15 - Adding flake (NixOS) --- backend/flake.lock | 27 +++++++++++++++++++++++++++ backend/flake.nix | 23 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 backend/flake.lock create mode 100644 backend/flake.nix diff --git a/backend/flake.lock b/backend/flake.lock new file mode 100644 index 00000000..f4191007 --- /dev/null +++ b/backend/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1708723535, + "narHash": "sha256-1z+3BHE9o1TfMpp7QAGAfu4+znaQv/47hIaV3n6HAuA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "27c12cd057b9dcd903a0ffb6a0712199cf4a66e1", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/backend/flake.nix b/backend/flake.nix new file mode 100644 index 00000000..b19eba33 --- /dev/null +++ b/backend/flake.nix @@ -0,0 +1,23 @@ +{ + description = "Python pip DevShell"; + + inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + + outputs = { self, nixpkgs }: let + systems = [ "x86_64-linux" ]; + forAllSystems = function: nixpkgs.lib.genAttrs systems (system: function { + pkgs = import nixpkgs { inherit system; config.allowUnfree = true; }; + }); + in { + devShells = forAllSystems ({ pkgs }: { + default = (pkgs.buildFHSUserEnv { + name = "pip-zone"; + targetPkgs = pkgs: with pkgs; [ + python311Full + python311Packages.pip + ]; + runScript = "zsh || bash"; + }).env; + }); + }; +} From 7f099c0de0c5a03f236bc138205eebdd9cbf76ac Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Sat, 24 Feb 2024 11:33:04 +0100 Subject: [PATCH 009/377] linting: fixed --- backend/project/endpoints/index/index.py | 2 +- backend/tests/endpoints/index_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/index/index.py b/backend/project/endpoints/index/index.py index a9829f38..ac76f624 100644 --- a/backend/project/endpoints/index/index.py +++ b/backend/project/endpoints/index/index.py @@ -1,7 +1,7 @@ """Index api point""" +import os from flask import Blueprint, send_from_directory from flask_restful import Resource, Api -import os index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) diff --git a/backend/tests/endpoints/index_test.py b/backend/tests/endpoints/index_test.py index e432de9f..200fbaeb 100644 --- a/backend/tests/endpoints/index_test.py +++ b/backend/tests/endpoints/index_test.py @@ -10,4 +10,4 @@ def test_openapi_spec(client): response = client.get("/") response_json = response.json assert response_json["openapi"] is not None - assert response_json["info"] is not None \ No newline at end of file + assert response_json["info"] is not None From d18d982c4d1b7a6115030eceeb2349173edb77fe Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Sat, 24 Feb 2024 11:39:22 +0100 Subject: [PATCH 010/377] linting: multi-line function docs ender on his own --- backend/project/endpoints/index/index.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/index/index.py b/backend/project/endpoints/index/index.py index ac76f624..1bfe67cb 100644 --- a/backend/project/endpoints/index/index.py +++ b/backend/project/endpoints/index/index.py @@ -10,8 +10,10 @@ class Index(Resource): """Api endpoint for the / route""" def get(self): - """Example of an api endpoint function that will respond to get requests made to / - return a json data structure with key Message and value Hello World!""" + """ + Example of an api endpoint function that will respond to get requests made to + return a json data structure with key Message and value Hello World! + """ dir_path = os.path.dirname(os.path.realpath(__file__)) return send_from_directory(dir_path, "OpenAPI_Object.json") From c3dd7b4b1d69eb835a7c2d3eb67e7890225e36db Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Sat, 24 Feb 2024 11:41:38 +0100 Subject: [PATCH 011/377] linting: function docs should use 3 quatation marks --- backend/tests/endpoints/index_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/index_test.py b/backend/tests/endpoints/index_test.py index 200fbaeb..5624bba7 100644 --- a/backend/tests/endpoints/index_test.py +++ b/backend/tests/endpoints/index_test.py @@ -6,7 +6,7 @@ def test_home(client): assert response.status_code == 200 def test_openapi_spec(client): - "Test whether the required fields of the openapi spec are present" + """Test whether the required fields of the openapi spec are present""" response = client.get("/") response_json = response.json assert response_json["openapi"] is not None From 42c19dbc4e3b67e41550ac97ee1023badacb589d Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 14:03:31 +0100 Subject: [PATCH 012/377] #15 - HTTP methods (skeleton) --- backend/project/__init__.py | 2 + backend/project/endpoints/submissions.py | 70 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 backend/project/endpoints/submissions.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 66435003..5ed5e524 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,7 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy from .endpoints.index import index_bp +from .endpoints.submissions import submissions_bp db = SQLAlchemy() @@ -17,6 +18,7 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) + app.register_blueprint(submissions_bp) return app diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py new file mode 100644 index 00000000..7c27bb86 --- /dev/null +++ b/backend/project/endpoints/submissions.py @@ -0,0 +1,70 @@ +"""Submission API endpoint""" + +from flask import Blueprint +from flask_restful import Resource + +submissions_bp = Blueprint("submissions", __name__) + +class Submissions(Resource): + """API endpoint for the submissions""" + + def get(self, uid: int, pid: int) -> dict[str, int]: + """Get all the submissions from a user for a project + + Args: + uid (int): User ID + pid (int): Project ID + + Returns: + dict[str, int]: The list of submission URLs + """ + return {"uid": uid, "pid": pid} + + def post(self, uid: int, pid: int) -> dict[str, int]: + """Post a new submission to a project + + Args: + uid (int): User ID + pid (int): Project ID + + Returns: + dict[str, int]: The URL to the submission + """ + return {"uid": uid, "pid": pid} + +submissions_bp.add_url_rule( + "/submissions//", + view_func=Submissions.as_view("submissions")) + +class Submission(Resource): + """API endpoint for the submission""" + + def get(self, uid: int, pid: int, sid: int) -> dict[str, int]: + """Get the submission given an submission ID + + Args: + uid (int): User ID + pid (int): Project ID + sid (int): Submission ID + + Returns: + dict[str, int]: The submission + """ + return {"uid": uid, "pid": pid, "sid": sid} + + def delete(self, uid: int, pid: int, sid: int) -> dict[str, int]: + """Delete a submission given an submission ID + + Args: + uid (int): User ID + pid (int): Project ID + sid (int): Submission ID + + Returns: + dict[str, int]: Empty + """ + return {"uid": uid, "pid": pid, "sid": sid} + +submissions_bp.add_url_rule( + "/submissions///", + view_func=Submission.as_view("submission")) From cabc3fe8400de320fe13050c495aaf540d7060c6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 15:44:55 +0100 Subject: [PATCH 013/377] #15 - Updating flake with database tools --- backend/flake.nix | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/flake.nix b/backend/flake.nix index b19eba33..3d88fde4 100644 --- a/backend/flake.nix +++ b/backend/flake.nix @@ -15,6 +15,9 @@ targetPkgs = pkgs: with pkgs; [ python311Full python311Packages.pip + + postgresql + dbeaver ]; runScript = "zsh || bash"; }).env; From c4eca1ccf63b2595e9bddb115933d3398407b1b8 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 24 Feb 2024 18:00:54 +0100 Subject: [PATCH 014/377] start setup --- backend/Dockerfile | 8 ++++++-- backend/project/endpoints/index/OpenAPI_Object.json | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 8e4fa18e..045286e7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,10 @@ FROM python:3.9 RUN mkdir /app -WORKDIR /app/ +WORKDIR /app ADD ./project /app/ +COPY requirements.txt /app/requirements.txt RUN pip3 install -r requirements.txt -CMD ["python3", "/app"] \ No newline at end of file +EXPOSE 5000 +COPY . /app +ENTRYPOINT ["python3"] +CMD ["__main__.py"] \ No newline at end of file diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 7243ff59..acf8e0ce 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -41,5 +41,5 @@ } ] }, - "paths": [] + "paths": ["/users"] } From c9b49e2a29dda139156774dbeded1adf25697045 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 24 Feb 2024 21:09:01 +0100 Subject: [PATCH 015/377] simple post --- backend/.gitignore | 1 + backend/project/__init__.py | 2 ++ backend/project/endpoints/users.py | 35 ++++++++++++++++++++++++++++ backend/tests/endpoints/user_test.py | 15 ++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 backend/project/endpoints/users.py create mode 100644 backend/tests/endpoints/user_test.py diff --git a/backend/.gitignore b/backend/.gitignore index 25343357..72cc6e44 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -9,3 +9,4 @@ docs/_build/ dist/ venv/ .env +.run/ diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 8b9f5f94..598221d5 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,7 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy from .endpoints.index.index import index_bp +from .endpoints.users import users_bp db = SQLAlchemy() @@ -17,6 +18,7 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) + app.register_blueprint(users_bp) return app diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py new file mode 100644 index 00000000..c4295b65 --- /dev/null +++ b/backend/project/endpoints/users.py @@ -0,0 +1,35 @@ +# users.py +from flask import Blueprint, request +from flask_restful import Resource, Api + +users_bp = Blueprint("users", __name__) +users_api = Api(users_bp) + +class Users(Resource): + """Api endpoint for the /users route""" + + + def post(self): + """ + This function will respond to post requests made to /users. + It should create a new user and return a success message. + """ + uid = request.json.get('uid') + is_teacher = request.json.get('is_teacher') + is_admin = request.json.get('is_admin') + + if is_teacher is None or is_admin is None or uid is None: + return { + "Message": "Invalid request data!", + "Correct Format": { + "uid": "User ID (string)", + "is_teacher": "Teacher status (boolean)", + "is_admin": "Admin status (boolean)" + } + }, 400 + + # Code to create a new user in the database using the uid, is_teacher, and is_admin values + + return {"Message": "User created successfully!"} + +users_api.add_resource(Users, "/users") \ No newline at end of file diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py new file mode 100644 index 00000000..3112ca30 --- /dev/null +++ b/backend/tests/endpoints/user_test.py @@ -0,0 +1,15 @@ +def test_simple(client): + """Test whether the users page is accessible""" + response = client.post("/users", json={ + 'uid': '123', + 'is_teacher': True, + 'is_admin': False + }) + assert response.status_code == 200 + # a test that should fail + response = client.post("/users", json={ + 'uid': '123', + 'is_student': True, #wrong field name + 'is_admin': False + }) + assert response.status_code == 400 \ No newline at end of file From 774601b21705737b6b55efd45bec2b67d49eeb34 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 24 Feb 2024 21:10:39 +0100 Subject: [PATCH 016/377] dockerfile requirements fixed --- backend/Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 045286e7..558bf735 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,7 +4,6 @@ WORKDIR /app ADD ./project /app/ COPY requirements.txt /app/requirements.txt RUN pip3 install -r requirements.txt -EXPOSE 5000 COPY . /app -ENTRYPOINT ["python3"] +ENTRYPOINT ["python"] CMD ["__main__.py"] \ No newline at end of file From 80f33a4f55895d83f855797c07d9082045b729de Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 24 Feb 2024 22:05:33 +0100 Subject: [PATCH 017/377] #15 - Local Flask server that can access a local database --- backend/project/__init__.py | 22 ++++++++++++++++++---- backend/project/__main__.py | 13 ++++++++++++- backend/project/database.py | 5 +++++ backend/project/endpoints/submissions.py | 10 +++++++--- backend/project/models/submissions.py | 2 +- 5 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 backend/project/database.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 5d79d95d..3b0c137b 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -2,13 +2,14 @@ This file is the base of the Flask API. It contains the basic structure of the API. """ +from os import getenv +from dotenv import load_dotenv +from sqlalchemy import URL from flask import Flask -from flask_sqlalchemy import SQLAlchemy +from .database import db from .endpoints.index.index import index_bp from .endpoints.submissions import submissions_bp -db = SQLAlchemy() - def create_app(): """ Create a Flask application instance. @@ -22,7 +23,7 @@ def create_app(): return app -def create_app_with_db(db_uri:str): +def create_app_with_db(db_uri: str = None): """ Initialize the database with the given uri and connect it to the app made with create_app. @@ -31,6 +32,19 @@ def create_app_with_db(db_uri:str): Returns: Flask -- A Flask application instance """ + + #$ flask --app project:create_app_with_db run + if db_uri is None: + load_dotenv() + db_uri = URL.create( + drivername=getenv("DB_DRIVER"), + username=getenv("DB_USER"), + password=getenv("DB_PASSWORD"), + host=getenv("DB_HOST"), + port=int(getenv("DB_PORT")), + database=getenv("DB_NAME") + ) + app = create_app() app.config["SQLALCHEMY_DATABASE_URI"] = db_uri db.init_app(app) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 9448b0eb..22c2a22e 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -2,11 +2,22 @@ from sys import path from os import getenv from dotenv import load_dotenv +from sqlalchemy import URL from project import create_app_with_db path.append(".") if __name__ == "__main__": load_dotenv() - app = create_app_with_db(getenv("DB_HOST")) + + url = URL.create( + drivername=getenv("DB_DRIVER"), + username=getenv("DB_USER"), + password=getenv("DB_PASSWORD"), + host=getenv("DB_HOST"), + port=int(getenv("DB_PORT")), + database=getenv("DB_NAME") + ) + + app = create_app_with_db(url) app.run(debug=True) diff --git a/backend/project/database.py b/backend/project/database.py new file mode 100644 index 00000000..b62b38c9 --- /dev/null +++ b/backend/project/database.py @@ -0,0 +1,5 @@ +"""Database file""" + +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 7c27bb86..64b859fc 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,13 +2,14 @@ from flask import Blueprint from flask_restful import Resource +from project.models.submissions import Submissions as m_submissions submissions_bp = Blueprint("submissions", __name__) class Submissions(Resource): """API endpoint for the submissions""" - def get(self, uid: int, pid: int) -> dict[str, int]: + def get(self, uid: str, pid: int) -> dict[str, any]: """Get all the submissions from a user for a project Args: @@ -18,7 +19,10 @@ def get(self, uid: int, pid: int) -> dict[str, int]: Returns: dict[str, int]: The list of submission URLs """ - return {"uid": uid, "pid": pid} + + a = m_submissions.query.filter_by(uid=uid, project_id=pid).count() + + return {"uid": uid, "pid": pid, "test": a} def post(self, uid: int, pid: int) -> dict[str, int]: """Post a new submission to a project @@ -33,7 +37,7 @@ def post(self, uid: int, pid: int) -> dict[str, int]: return {"uid": uid, "pid": pid} submissions_bp.add_url_rule( - "/submissions//", + "/submissions//", view_func=Submissions.as_view("submissions")) class Submission(Resource): diff --git a/backend/project/models/submissions.py b/backend/project/models/submissions.py index 97e8762c..a612b48d 100644 --- a/backend/project/models/submissions.py +++ b/backend/project/models/submissions.py @@ -1,7 +1,7 @@ """Model for submissions""" from sqlalchemy import Column,String,ForeignKey,Integer,CheckConstraint,DateTime,Boolean -from project import db +from project.database import db class Submissions(db.Model): """This class describes the submissions table, From de1112404e2b6ee04e8766eedd2984a987e53a83 Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 12:30:43 +0100 Subject: [PATCH 018/377] users update added --- backend/project/__init__.py | 5 ++-- backend/project/__main__.py | 14 +++++++++-- backend/project/endpoints/users.py | 38 +++++++++++++++++++++++++----- backend/requirements.txt | 8 ++++--- 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 598221d5..e02f989d 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -4,8 +4,7 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy -from .endpoints.index.index import index_bp -from .endpoints.users import users_bp + db = SQLAlchemy() @@ -15,6 +14,8 @@ def create_app(): Returns: Flask -- A Flask application instance """ + from .endpoints.index.index import index_bp + from .endpoints.users import users_bp app = Flask(__name__) app.register_blueprint(index_bp) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 9448b0eb..510e3bea 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -2,11 +2,21 @@ from sys import path from os import getenv from dotenv import load_dotenv +from sqlalchemy import URL from project import create_app_with_db path.append(".") if __name__ == "__main__": load_dotenv() - app = create_app_with_db(getenv("DB_HOST")) - app.run(debug=True) + + url = URL.create( + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_DB") + ) + + app = create_app_with_db(url) + app.run(debug=True) \ No newline at end of file diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index c4295b65..faad37c3 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -1,14 +1,16 @@ -# users.py +"""Users api endpoint""" from flask import Blueprint, request from flask_restful import Resource, Api +from project import db +from project.models.users import Users as UserModel users_bp = Blueprint("users", __name__) users_api = Api(users_bp) + class Users(Resource): """Api endpoint for the /users route""" - def post(self): """ This function will respond to post requests made to /users. @@ -25,11 +27,35 @@ def post(self): "uid": "User ID (string)", "is_teacher": "Teacher status (boolean)", "is_admin": "Admin status (boolean)" - } - }, 400 - + } + }, 400 + # Code to create a new user in the database using the uid, is_teacher, and is_admin values + new_user = UserModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) + db.session.add(new_user) + db.session.commit() return {"Message": "User created successfully!"} -users_api.add_resource(Users, "/users") \ No newline at end of file + def update(self): + uid = request.json.get('uid') + is_teacher = request.json.get('is_teacher') + is_admin = request.json.get('is_admin') + if uid is None: + return {"Message": "User ID is required!"}, 400 + + user = UserModel.query.get(uid) + if user is None: + return {"Message": "User not found!"}, 404 + + if is_teacher is not None: + user.is_teacher = is_teacher + if is_admin is not None: + user.is_admin = is_admin + + # Save the changes to the database + db.session.commit() + return {"Message": "User updated successfully!"} + + +users_api.add_resource(Users, "/users") diff --git a/backend/requirements.txt b/backend/requirements.txt index 943fb75a..0076b0f8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,7 @@ -flask +flask~=3.0.2 flask-restful flask-sqlalchemy -python-dotenv -psycopg2-binary \ No newline at end of file +python-dotenv~=1.0.1 +psycopg2-binary +pytest~=8.0.1 +SQLAlchemy~=2.0.27 \ No newline at end of file From 7b3f7f90534c3da346a0994d0ee84987d7069ca8 Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 12:59:27 +0100 Subject: [PATCH 019/377] users get added --- backend/project/endpoints/users.py | 45 ++++++++++++++++++---------- backend/tests/endpoints/conftest.py | 16 ++++++++-- backend/tests/endpoints/user_test.py | 4 +-- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index faad37c3..fc12d334 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -10,6 +10,15 @@ class Users(Resource): """Api endpoint for the /users route""" + + def get(self): + """ + This function will respond to get requests made to /users. + It should return all users from the database. + """ + users = UserModel.query.all() + users_list = [{"uid": user.uid, "is_teacher": user.is_teacher, "is_admin": user.is_admin} for user in users] + return users_list def post(self): """ @@ -38,24 +47,30 @@ def post(self): return {"Message": "User created successfully!"} def update(self): - uid = request.json.get('uid') - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') - if uid is None: - return {"Message": "User ID is required!"}, 400 + """ + Update the user's information. - user = UserModel.query.get(uid) - if user is None: - return {"Message": "User not found!"}, 404 + Returns: + dict: A dictionary containing the message indicating the success or failure of the update. + """ + uid = request.json.get('uid') + is_teacher = request.json.get('is_teacher') + is_admin = request.json.get('is_admin') + if uid is None: + return {"Message": "User ID is required!"}, 400 - if is_teacher is not None: - user.is_teacher = is_teacher - if is_admin is not None: - user.is_admin = is_admin + user = UserModel.query.get(uid) + if user is None: + return {"Message": "User not found!"}, 404 - # Save the changes to the database - db.session.commit() - return {"Message": "User updated successfully!"} + if is_teacher is not None: + user.is_teacher = is_teacher + if is_admin is not None: + user.is_admin = is_admin + + # Save the changes to the database + db.session.commit() + return {"Message": "User updated successfully!"} users_api.add_resource(Users, "/users") diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 010ef293..21556ad0 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,6 +1,9 @@ """ Configuration for pytest, Flask, and the test client.""" +from os import getenv +from dotenv import load_dotenv +from sqlalchemy import URL import pytest -from project import create_app +from project import create_app_with_db @pytest.fixture def app(): @@ -8,7 +11,16 @@ def app(): Returns: Flask -- A Flask application instance """ - app = create_app() + load_dotenv() + + url = URL.create( + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_DB") + ) + app = create_app_with_db(url) yield app @pytest.fixture diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 3112ca30..82af395c 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -1,14 +1,14 @@ def test_simple(client): """Test whether the users page is accessible""" response = client.post("/users", json={ - 'uid': '123', + 'uid': '12', 'is_teacher': True, 'is_admin': False }) assert response.status_code == 200 # a test that should fail response = client.post("/users", json={ - 'uid': '123', + 'uid': '12', 'is_student': True, #wrong field name 'is_admin': False }) From 4cb75d9dd9a916c500c9a1eef433a1c75d634dff Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 13:24:16 +0100 Subject: [PATCH 020/377] delete added --- backend/project/endpoints/users.py | 22 +++++++++++-- backend/tests/endpoints/user_test.py | 47 ++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index fc12d334..44d7e1b0 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -10,7 +10,7 @@ class Users(Resource): """Api endpoint for the /users route""" - + def get(self): """ This function will respond to get requests made to /users. @@ -46,7 +46,7 @@ def post(self): db.session.commit() return {"Message": "User created successfully!"} - def update(self): + def patch(self): """ Update the user's information. @@ -71,6 +71,24 @@ def update(self): # Save the changes to the database db.session.commit() return {"Message": "User updated successfully!"} + + def delete(self): + """ + This function will respond to DELETE requests made to /users. + It should delete a user and return a success message. + """ + uid = request.json.get('uid') + + if uid is None: + return {"Message": "User ID is required!"}, 400 + + user = UserModel.query.get(uid) + if user is None: + return {"Message": "User not found!"}, 404 + + db.session.delete(user) + db.session.commit() + return {"Message": "User deleted successfully!"} users_api.add_resource(Users, "/users") diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 82af395c..eec8ecc0 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -1,15 +1,56 @@ -def test_simple(client): +def test_post_delete_user(client): """Test whether the users page is accessible""" response = client.post("/users", json={ - 'uid': '12', + 'uid': 'del', 'is_teacher': True, 'is_admin': False }) assert response.status_code == 200 + # Delete the user + response = client.delete("/users", json={'uid': 'del'}) + assert response.status_code == 200 + assert response.json == {"Message": "User deleted successfully!"} + + # Try to delete the user again + response = client.delete("/users", json={'uid': 'del'}) + assert response.status_code == 404 # a test that should fail response = client.post("/users", json={ 'uid': '12', 'is_student': True, #wrong field name 'is_admin': False }) - assert response.status_code == 400 \ No newline at end of file + assert response.status_code == 400 + +def test_get_users(client): + """Test the get method of the Users class""" + response = client.get("/users") + assert response.status_code == 200 + # Check that the response is a list (even if it's empty) + assert isinstance(response.json, list) + +def test_patch_user(client): + """Test the update method of the Users class""" + # First, create a user to update + client.post("/users", json={ + 'uid': 'pat', + 'is_teacher': True, + 'is_admin': False + }) + + # Then, update the user + response = client.patch("/users", json={ + 'uid': 'pat', + 'is_teacher': False, + 'is_admin': True + }) + assert response.status_code == 200 + assert response.json == {"Message": "User updated successfully!"} + + # Try to update a non-existent user + response = client.patch("/users", json={ + 'uid': 'non', + 'is_teacher': False, + 'is_admin': True + }) + assert response.status_code == 404 \ No newline at end of file From 958ea145d698f060878b3b67f135957b2c110346 Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 13:49:36 +0100 Subject: [PATCH 021/377] confest changed --- backend/project/endpoints/users.py | 2 +- backend/tests/endpoints/conftest.py | 38 ++++++++++++++++++++++------ backend/tests/endpoints/user_test.py | 2 +- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 44d7e1b0..33b70454 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -88,7 +88,7 @@ def delete(self): db.session.delete(user) db.session.commit() - return {"Message": "User deleted successfully!"} + return {"Message": f"User with id: {uid} deleted successfully!"}, 200 users_api.add_resource(Users, "/users") diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 21556ad0..31c89251 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -4,6 +4,19 @@ from sqlalchemy import URL import pytest from project import create_app_with_db +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from project import db + +load_dotenv() + +url = URL.create( + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_DB") +) @pytest.fixture def app(): @@ -11,18 +24,27 @@ def app(): Returns: Flask -- A Flask application instance """ - load_dotenv() - url = URL.create( - drivername="postgresql", - username=getenv("POSTGRES_USER"), - password=getenv("POSTGRES_PASSWORD"), - host=getenv("POSTGRES_HOST"), - database=getenv("POSTGRES_DB") - ) app = create_app_with_db(url) yield app + +engine = create_engine(url) +Session = sessionmaker(bind=engine) + +@pytest.fixture +def db_session(): + """Create a new database session for a test. + After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) + session = Session() + yield session + session.rollback() + session.close() + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() @pytest.fixture def client(app): """A fixture that creates a test client for the app. diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index eec8ecc0..e2c0f127 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -9,7 +9,7 @@ def test_post_delete_user(client): # Delete the user response = client.delete("/users", json={'uid': 'del'}) assert response.status_code == 200 - assert response.json == {"Message": "User deleted successfully!"} + assert response.json == {"Message": f"User with id: del deleted successfully!"} # Try to delete the user again response = client.delete("/users", json={'uid': 'del'}) From 434f179c80c887d6e2a62aca79e6accac8abb4cb Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 15:11:15 +0100 Subject: [PATCH 022/377] #15 - Fix linter use of duplicate code fragment --- backend/project/__init__.py | 16 ++-------------- backend/project/__main__.py | 18 +++--------------- backend/project/database.py | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 29 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 3b0c137b..ec71b0f8 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -2,11 +2,8 @@ This file is the base of the Flask API. It contains the basic structure of the API. """ -from os import getenv -from dotenv import load_dotenv -from sqlalchemy import URL from flask import Flask -from .database import db +from .database import db, get_database_uri from .endpoints.index.index import index_bp from .endpoints.submissions import submissions_bp @@ -20,7 +17,6 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) app.register_blueprint(submissions_bp) - return app def create_app_with_db(db_uri: str = None): @@ -35,15 +31,7 @@ def create_app_with_db(db_uri: str = None): #$ flask --app project:create_app_with_db run if db_uri is None: - load_dotenv() - db_uri = URL.create( - drivername=getenv("DB_DRIVER"), - username=getenv("DB_USER"), - password=getenv("DB_PASSWORD"), - host=getenv("DB_HOST"), - port=int(getenv("DB_PORT")), - database=getenv("DB_NAME") - ) + db_uri = get_database_uri() app = create_app() app.config["SQLALCHEMY_DATABASE_URI"] = db_uri diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 22c2a22e..8945aa1f 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,23 +1,11 @@ """Main entry point for the application.""" + from sys import path -from os import getenv -from dotenv import load_dotenv -from sqlalchemy import URL from project import create_app_with_db +from project.database import get_database_uri path.append(".") if __name__ == "__main__": - load_dotenv() - - url = URL.create( - drivername=getenv("DB_DRIVER"), - username=getenv("DB_USER"), - password=getenv("DB_PASSWORD"), - host=getenv("DB_HOST"), - port=int(getenv("DB_PORT")), - database=getenv("DB_NAME") - ) - - app = create_app_with_db(url) + app = create_app_with_db(get_database_uri()) app.run(debug=True) diff --git a/backend/project/database.py b/backend/project/database.py index b62b38c9..1c879be5 100644 --- a/backend/project/database.py +++ b/backend/project/database.py @@ -1,5 +1,25 @@ """Database file""" +from os import getenv from flask_sqlalchemy import SQLAlchemy +from dotenv import load_dotenv +from sqlalchemy import URL db = SQLAlchemy() + +def get_database_uri() -> str: + """Get the database URI made from environment variables + + Returns: + str: Database URI + """ + load_dotenv() + uri = URL.create( + drivername=getenv("DB_DRIVER"), + username=getenv("DB_USER"), + password=getenv("DB_PASSWORD"), + host=getenv("DB_HOST"), + port=int(getenv("DB_PORT")), + database=getenv("DB_NAME") + ) + return uri From c6a2e99aad6b08d7968b89112371f7cde804127e Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 16:34:45 +0100 Subject: [PATCH 023/377] confest changed --- backend/tests/endpoints/conftest.py | 35 +++++++++-------------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 31c89251..f657a2fd 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -8,15 +8,7 @@ from sqlalchemy.orm import sessionmaker from project import db -load_dotenv() -url = URL.create( - drivername="postgresql", - username=getenv("POSTGRES_USER"), - password=getenv("POSTGRES_PASSWORD"), - host=getenv("POSTGRES_HOST"), - database=getenv("POSTGRES_DB") -) @pytest.fixture def app(): @@ -24,27 +16,22 @@ def app(): Returns: Flask -- A Flask application instance """ + load_dotenv() + url = URL.create( + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_DB") + ) + engine = create_engine(url) + Session = sessionmaker(bind=engine) app = create_app_with_db(url) + db.metadata.create_all(engine) yield app -engine = create_engine(url) -Session = sessionmaker(bind=engine) - -@pytest.fixture -def db_session(): - """Create a new database session for a test. - After the test, all changes are rolled back and the session is closed.""" - db.metadata.create_all(engine) - session = Session() - yield session - session.rollback() - session.close() - # Truncate all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() @pytest.fixture def client(app): """A fixture that creates a test client for the app. From f072cfc90bbc9f07210e8728909f29c4b740616d Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 16:41:03 +0100 Subject: [PATCH 024/377] cleaned up the code --- backend/tests/__init__.py | 16 +++++++++++++ backend/tests/endpoints/conftest.py | 19 +++------------ backend/tests/models/conftest.py | 36 +++++++++++++---------------- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py index e69de29b..3670f956 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -0,0 +1,16 @@ +import os +from sqlalchemy.engine.url import URL +from dotenv import load_dotenv +load_dotenv() + +DATABSE_NAME = os.getenv('POSTGRES_DB') +DATABASE_USER = os.getenv('POSTGRES_USER') +DATABASE_PASSWORD = os.getenv('POSTGRES_PASSWORD') +DATABASE_HOST = os.getenv('POSTGRES_HOST') +db_url = URL.create( + drivername="postgresql", + username=DATABASE_USER, + host=DATABASE_HOST, + database=DATABSE_NAME, + password=DATABASE_PASSWORD +) \ No newline at end of file diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index f657a2fd..a4bf9339 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,12 +1,9 @@ """ Configuration for pytest, Flask, and the test client.""" -from os import getenv -from dotenv import load_dotenv -from sqlalchemy import URL import pytest from project import create_app_with_db from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker from project import db +from tests import db_url @@ -16,18 +13,8 @@ def app(): Returns: Flask -- A Flask application instance """ - load_dotenv() - - url = URL.create( - drivername="postgresql", - username=getenv("POSTGRES_USER"), - password=getenv("POSTGRES_PASSWORD"), - host=getenv("POSTGRES_HOST"), - database=getenv("POSTGRES_DB") - ) - engine = create_engine(url) - Session = sessionmaker(bind=engine) - app = create_app_with_db(url) + engine = create_engine(db_url) + app = create_app_with_db(db_url) db.metadata.create_all(engine) yield app diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 150d433e..d21d7b9a 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -14,25 +14,12 @@ from project.models.course_relations import CourseAdmins, CourseStudents from project.models.projects import Projects from project.models.users import Users +from tests import db_url -load_dotenv() - -DATABSE_NAME = os.getenv('POSTGRES_DB') -DATABASE_USER = os.getenv('POSTGRES_USER') -DATABASE_PASSWORD = os.getenv('POSTGRES_PASSWORD') -DATABASE_HOST = os.getenv('POSTGRES_HOST') - -url = URL.create( - drivername="postgresql", - username=DATABASE_USER, - host=DATABASE_HOST, - database=DATABSE_NAME, - password=DATABASE_PASSWORD -) - -engine = create_engine(url) +engine = create_engine(db_url) Session = sessionmaker(bind=engine) + @pytest.fixture def db_session(): """Create a new database session for a test. @@ -47,60 +34,69 @@ def db_session(): session.execute(table.delete()) session.commit() + @pytest.fixture def valid_user(): """A valid user for testing""" user = Users(uid="student", is_teacher=False, is_admin=False) return user + @pytest.fixture def teachers(): """A list of 10 teachers for testing""" users = [Users(uid=str(i), is_teacher=True, is_admin=False) for i in range(10)] return users + @pytest.fixture def course_teacher(): """A user that's a teacher for for testing""" sel2_teacher = Users(uid="Bart", is_teacher=True, is_admin=False) return sel2_teacher + @pytest.fixture def course(course_teacher): """A course for testing, with the course teacher as the teacher.""" sel2 = Courses(name="Sel2", teacher=course_teacher.uid) return sel2 + @pytest.fixture def course_students(): """A list of 5 students for testing.""" students = [ Users(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) - for i in range(5) + for i in range(5) ] return students + @pytest.fixture -def course_students_relation(course,course_students): +def course_students_relation(course, course_students): """A list of 5 course relations for testing.""" course_relations = [ CourseStudents(course_id=course.course_id, uid=course_students[i].uid) - for i in range(5) + for i in range(5) ] return course_relations + @pytest.fixture def assistent(): """An assistent for testing.""" assist = Users(uid="assistent_sel2") return assist + @pytest.fixture() -def course_admin(course,assistent): +def course_admin(course, assistent): """A course admin for testing.""" admin_relation = CourseAdmins(uid=assistent.uid, course_id=course.course_id) return admin_relation + @pytest.fixture() def valid_project(): """A valid project for testing.""" From 3006e43861af79446d7cbebb7879af224820b18c Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 16:55:04 +0100 Subject: [PATCH 025/377] fixed linter --- backend/project/endpoints/users.py | 57 +++++++++++++++------------- backend/tests/__init__.py | 12 +++++- backend/tests/endpoints/conftest.py | 2 +- backend/tests/endpoints/user_test.py | 13 ++++++- backend/tests/models/conftest.py | 3 -- 5 files changed, 53 insertions(+), 34 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 33b70454..f7534e68 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -17,7 +17,8 @@ def get(self): It should return all users from the database. """ users = UserModel.query.all() - users_list = [{"uid": user.uid, "is_teacher": user.is_teacher, "is_admin": user.is_admin} for user in users] + users_list = [{"uid": user.uid, "is_teacher": user.is_teacher, "is_admin": user.is_admin} + for user in users] return users_list def post(self): @@ -47,31 +48,33 @@ def post(self): return {"Message": "User created successfully!"} def patch(self): - """ - Update the user's information. - - Returns: - dict: A dictionary containing the message indicating the success or failure of the update. - """ - uid = request.json.get('uid') - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') - if uid is None: - return {"Message": "User ID is required!"}, 400 - - user = UserModel.query.get(uid) - if user is None: - return {"Message": "User not found!"}, 404 - - if is_teacher is not None: - user.is_teacher = is_teacher - if is_admin is not None: - user.is_admin = is_admin - - # Save the changes to the database - db.session.commit() - return {"Message": "User updated successfully!"} - + """ + Update the user's information. + + Returns: + dict: A dictionary containing the message indicating the success + or failure of the update. + """ + uid = request.json.get('uid') + is_teacher = request.json.get('is_teacher') + is_admin = request.json.get('is_admin') + if uid is None: + return {"Message": "User ID is required!"}, 400 + + + user = db.session.get(UserModel,uid) + if user is None: + return {"Message": "User not found!"}, 404 + + if is_teacher is not None: + user.is_teacher = is_teacher + if is_admin is not None: + user.is_admin = is_admin + + # Save the changes to the database + db.session.commit() + return {"Message": "User updated successfully!"} + def delete(self): """ This function will respond to DELETE requests made to /users. @@ -82,7 +85,7 @@ def delete(self): if uid is None: return {"Message": "User ID is required!"}, 400 - user = UserModel.query.get(uid) + user = db.session.get(UserModel, uid) if user is None: return {"Message": "User not found!"}, 404 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py index 3670f956..ec43b874 100644 --- a/backend/tests/__init__.py +++ b/backend/tests/__init__.py @@ -1,3 +1,13 @@ +""" +This module is used to create a SQLAlchemy URL object for a PostgreSQL database. + +It uses environment variables to get the necessary database configuration details: +- 'POSTGRES_DB': The name of the database. +- 'POSTGRES_USER': The username to connect to the database. +- 'POSTGRES_PASSWORD': The password to connect to the database. +- 'POSTGRES_HOST': The host where the database is located. + +""" import os from sqlalchemy.engine.url import URL from dotenv import load_dotenv @@ -13,4 +23,4 @@ host=DATABASE_HOST, database=DATABSE_NAME, password=DATABASE_PASSWORD -) \ No newline at end of file +) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index a4bf9339..20c6961a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,7 +1,7 @@ """ Configuration for pytest, Flask, and the test client.""" import pytest -from project import create_app_with_db from sqlalchemy import create_engine +from project import create_app_with_db from project import db from tests import db_url diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index e2c0f127..236d903d 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -1,3 +1,12 @@ +""" +This module tests user management endpoints. + +- test_post_delete_user: Tests user creation, deletion, and error handling for deletion + of non-existent user. +- test_get_users: Tests retrieval of all users, ensuring the response is a list. +- test_patch_user: Tests user update functionality and error handling for updating + non-existent user. +""" def test_post_delete_user(client): """Test whether the users page is accessible""" response = client.post("/users", json={ @@ -9,7 +18,7 @@ def test_post_delete_user(client): # Delete the user response = client.delete("/users", json={'uid': 'del'}) assert response.status_code == 200 - assert response.json == {"Message": f"User with id: del deleted successfully!"} + assert response.json == {"Message": "User with id: del deleted successfully!"} # Try to delete the user again response = client.delete("/users", json={'uid': 'del'}) @@ -53,4 +62,4 @@ def test_patch_user(client): 'is_teacher': False, 'is_admin': True }) - assert response.status_code == 404 \ No newline at end of file + assert response.status_code == 404 diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index d21d7b9a..a3d44c66 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -2,12 +2,9 @@ Configuration for the models tests. Contains all the fixtures needed for multiple models tests. """ -import os from datetime import datetime from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from sqlalchemy.engine.url import URL -from dotenv import load_dotenv import pytest from project import db from project.models.courses import Courses From 69c95adf701abe37d4f7aa03dd5dbf76395e5063 Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 16:58:52 +0100 Subject: [PATCH 026/377] fixed linter --- backend/project/__main__.py | 2 +- backend/pylintrc | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 510e3bea..343e6af8 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -19,4 +19,4 @@ ) app = create_app_with_db(url) - app.run(debug=True) \ No newline at end of file + app.run(debug=True) diff --git a/backend/pylintrc b/backend/pylintrc index 83eff274..9150b9e2 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -1,10 +1,15 @@ [MASTER] init-hook='import sys; sys.path.append(".")' +[MESSAGES CONTROL] +disable=W0621, C0415 + + [test-files:*_test.py] disable= W0621, # Redefining name %r from outer scope (line %s) [modules:project/modules/*] disable= - R0903 # Too few public methods (modules don't require us to have public methods) + R0903, # Too few public methods (modules don't require us to have public methods) + C0415 # Import outside toplevel From 1f25ba242be5427ff66cc193b8c441b67f1f3f4d Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 25 Feb 2024 20:32:47 +0100 Subject: [PATCH 027/377] requested changes --- .../endpoints/index/OpenAPI_Object.json | 186 +++++++++++++++++- backend/project/endpoints/users.py | 109 ++++++---- backend/project/models/users.py | 9 +- backend/pylintrc | 3 +- backend/tests/endpoints/user_test.py | 47 ++++- 5 files changed, 298 insertions(+), 56 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index acf8e0ce..f952261d 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -41,5 +41,189 @@ } ] }, - "paths": ["/users"] + "paths": { + "/users": { + "get": { + "summary": "Get all users", + "responses": { + "200": { + "description": "A list of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["uid", "is_teacher", "is_admin"] + } + } + } + } + }}}, + "post": { + "summary": "Create a new user", + "requestBody": { + "required": true, + "content":{ + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["uid", "is_teacher", "is_admin"] + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully" + }, + "400": { + "description": "Invalid request data" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while creating the user" + } + } + + }, + "/users/{user_id}": { + "get": { + "summary": "Get a user by ID", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A user", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["uid", "is_teacher", "is_admin"] + } + } + } + }, + "404": { + "description": "User not found" + } + } + }, + "patch": { + "summary": "Update a user's information", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + } + }, + "required": ["is_teacher", "is_admin"] + } + } + } + }, + "responses": { + "200": { + "description": "User updated successfully" + }, + "404": { + "description": "User not found" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while patching the user" + } + } + }, + "delete": { + "summary": "Delete a user", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted successfully" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "An error occurred while deleting the user" + } + } + } + } + } + } } + diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index f7534e68..915bdc82 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -1,5 +1,5 @@ """Users api endpoint""" -from flask import Blueprint, request +from flask import Blueprint, request, jsonify from flask_restful import Resource, Api from project import db from project.models.users import Users as UserModel @@ -17,15 +17,17 @@ def get(self): It should return all users from the database. """ users = UserModel.query.all() - users_list = [{"uid": user.uid, "is_teacher": user.is_teacher, "is_admin": user.is_admin} - for user in users] - return users_list + + return jsonify(users) def post(self): """ This function will respond to post requests made to /users. It should create a new user and return a success message. """ + if not request.is_json: + return {"Message": "Unsupported Media Type. Expected JSON."}, 415 + uid = request.json.get('uid') is_teacher = request.json.get('is_teacher') is_admin = request.json.get('is_admin') @@ -39,15 +41,39 @@ def post(self): "is_admin": "Admin status (boolean)" } }, 400 + try: + user = db.session.get(UserModel, uid) + if user is not None: + # bad request, error code could be 409 but is rarely used + return {"Message": f"User {uid} already exists"}, 400 + # Code to create a new user in the database using the uid, is_teacher, and is_admin values + new_user = UserModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) + db.session.add(new_user) + db.session.commit() + + except Exception as e: + db.session.rollback() + return {"Message": f"An error occurred while creating the user: {str(e)}"}, 500 + + return {"Message": "User created successfully!"}, 201 + +users_api.add_resource(Users, "/users") + - # Code to create a new user in the database using the uid, is_teacher, and is_admin values +class User(Resource): + """Api endpoint for the /users/{user_id} route""" + def get(self, user_id): + """ + This function will respond to GET requests made to /users/. + It should return the user with the given user_id from the database. + """ + user = db.session.get(UserModel,user_id) + if user is None: + return {"Message": "User not found!"}, 404 - new_user = UserModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) - db.session.add(new_user) - db.session.commit() - return {"Message": "User created successfully!"} + return jsonify(user) - def patch(self): + def patch(self, user_id): """ Update the user's information. @@ -55,43 +81,44 @@ def patch(self): dict: A dictionary containing the message indicating the success or failure of the update. """ - uid = request.json.get('uid') + if not request.is_json: + return {"Message": "Unsupported Media Type. Expected JSON."}, 415 + is_teacher = request.json.get('is_teacher') is_admin = request.json.get('is_admin') - if uid is None: - return {"Message": "User ID is required!"}, 400 - - - user = db.session.get(UserModel,uid) - if user is None: - return {"Message": "User not found!"}, 404 - - if is_teacher is not None: - user.is_teacher = is_teacher - if is_admin is not None: - user.is_admin = is_admin - - # Save the changes to the database - db.session.commit() + try: + user = db.session.get(UserModel,user_id) + if user is None: + return {"Message": "User not found!"}, 404 + + if is_teacher is not None: + user.is_teacher = is_teacher + if is_admin is not None: + user.is_admin = is_admin + + # Save the changes to the database + db.session.commit() + except Exception as e: + db.session.rollback() + return {"Message": f"An error occurred while patching the user: {str(e)}"}, 500 return {"Message": "User updated successfully!"} - def delete(self): + def delete(self, user_id): """ - This function will respond to DELETE requests made to /users. - It should delete a user and return a success message. + This function will respond to DELETE requests made to /users/. + It should delete the user with the given user_id from the database. """ - uid = request.json.get('uid') + try: + user = db.session.get(UserModel,user_id) + if user is None: + return {"Message": "User not found!"}, 404 - if uid is None: - return {"Message": "User ID is required!"}, 400 + db.session.delete(user) + db.session.commit() + except Exception as e: + db.session.rollback() + return {"Message": f"An error occurred while deleting the user: {str(e)}"}, 500 + return {"Message": "User deleted successfully!"} - user = db.session.get(UserModel, uid) - if user is None: - return {"Message": "User not found!"}, 404 - - db.session.delete(user) - db.session.commit() - return {"Message": f"User with id: {uid} deleted successfully!"}, 200 - -users_api.add_resource(Users, "/users") +users_api.add_resource(User, "/users/") diff --git a/backend/project/models/users.py b/backend/project/models/users.py index c3ea45c0..8a27353a 100644 --- a/backend/project/models/users.py +++ b/backend/project/models/users.py @@ -1,9 +1,10 @@ """Model for users""" +import dataclasses from sqlalchemy import Boolean, Column, String from project import db - +@dataclasses.dataclass class Users(db.Model): """This class defines the users table, a user has an uid, @@ -11,6 +12,6 @@ class Users(db.Model): can be either a student,admin or teacher""" __tablename__ = "users" - uid = Column(String(255), primary_key=True) - is_teacher = Column(Boolean) - is_admin = Column(Boolean) + uid:str = Column(String(255), primary_key=True) + is_teacher:bool = Column(Boolean) + is_admin:bool = Column(Boolean) diff --git a/backend/pylintrc b/backend/pylintrc index 9150b9e2..ea4a6d93 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -2,7 +2,8 @@ init-hook='import sys; sys.path.append(".")' [MESSAGES CONTROL] -disable=W0621, C0415 +disable=W0621, # Redefining name %r from outer scope (line %s) + C0415 # Import outside toplevel (needed to prevent circular imports in project/__init__.py) [test-files:*_test.py] diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 236d903d..3acd8c81 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -14,23 +14,31 @@ def test_post_delete_user(client): 'is_teacher': True, 'is_admin': False }) - assert response.status_code == 200 + assert response.status_code == 201 or response.status_code == 400 # already present # Delete the user - response = client.delete("/users", json={'uid': 'del'}) + response = client.delete("/users/del") assert response.status_code == 200 - assert response.json == {"Message": "User with id: del deleted successfully!"} + assert response.json == {"Message": "User deleted successfully!"} # Try to delete the user again - response = client.delete("/users", json={'uid': 'del'}) + response = client.delete("/users/del") assert response.status_code == 404 # a test that should fail response = client.post("/users", json={ 'uid': '12', - 'is_student': True, #wrong field name + 'is_student': True, # wrong field name 'is_admin': False }) assert response.status_code == 400 + # Send a request with a media type that's not JSON + response = client.post("/users", data={ + 'uid': '12', + 'is_teacher': True, + 'is_admin': False + }) + assert response.status_code == 415 + def test_get_users(client): """Test the get method of the Users class""" response = client.get("/users") @@ -38,6 +46,21 @@ def test_get_users(client): # Check that the response is a list (even if it's empty) assert isinstance(response.json, list) + response = client.post("/users", json={ + 'uid': 'u_get', + 'is_teacher': True, + 'is_admin': False + }) + assert response.status_code == 201 or response.status_code == 400 + response = client.get("users/u_get") + assert response.status_code == 200 + assert response.json == { + 'uid': 'u_get', + 'is_teacher': True, + 'is_admin': False + } + + def test_patch_user(client): """Test the update method of the Users class""" # First, create a user to update @@ -48,8 +71,7 @@ def test_patch_user(client): }) # Then, update the user - response = client.patch("/users", json={ - 'uid': 'pat', + response = client.patch("/users/pat", json={ 'is_teacher': False, 'is_admin': True }) @@ -57,9 +79,16 @@ def test_patch_user(client): assert response.json == {"Message": "User updated successfully!"} # Try to update a non-existent user - response = client.patch("/users", json={ - 'uid': 'non', + response = client.patch("/users/non", json={ 'is_teacher': False, 'is_admin': True }) assert response.status_code == 404 + + # Send a request with a media type that's not JSON + response = client.post("/users", data={ + 'uid': '12', + 'is_teacher': True, + 'is_admin': False + }) + assert response.status_code == 415 From c5399f5afe26a8745db546e814b3f766624e0b0e Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 22:32:12 +0100 Subject: [PATCH 028/377] #15 - Most methods implemented. Still linting issues, submissions patch needed, tests, authentication --- backend/project/__main__.py | 2 +- backend/project/endpoints/submissions.py | 178 ++++++++++++++++++++--- 2 files changed, 157 insertions(+), 23 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 8945aa1f..4980ef8a 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -2,7 +2,7 @@ from sys import path from project import create_app_with_db -from project.database import get_database_uri +from .database import get_database_uri path.append(".") diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 64b859fc..631ec927 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -1,8 +1,12 @@ """Submission API endpoint""" -from flask import Blueprint +from datetime import datetime +from flask import Blueprint, request from flask_restful import Resource +from project.database import db from project.models.submissions import Submissions as m_submissions +from project.models.projects import Projects as m_projects +from project.models.users import Users as m_users submissions_bp = Blueprint("submissions", __name__) @@ -13,28 +17,106 @@ def get(self, uid: str, pid: int) -> dict[str, any]: """Get all the submissions from a user for a project Args: - uid (int): User ID + uid (str): User ID pid (int): Project ID Returns: - dict[str, int]: The list of submission URLs + dict[str, any]: The list of submission URLs """ - a = m_submissions.query.filter_by(uid=uid, project_id=pid).count() - - return {"uid": uid, "pid": pid, "test": a} - - def post(self, uid: int, pid: int) -> dict[str, int]: + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Check user + user = session.get(m_users, uid) + if user is None: + return {"message": f"User {uid} not found"}, 404 + + # Check project + project = session.get(m_projects, pid) + if project is None: + return {"message": f"Project {pid} not found"}, 404 + + # Get the submissions + submissions = session.query(m_submissions).filter_by(uid=uid, project_id=pid).all() + submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] + return {"submissions": submissions_urls} + except Exception: + return {"message": f"An error occurred while fetching the submissions from user {uid} for project {pid}"}, 500 + + def post(self, uid: str, pid: int) -> dict[str, any]: """Post a new submission to a project Args: - uid (int): User ID + uid (str): User ID pid (int): Project ID Returns: - dict[str, int]: The URL to the submission + dict[str, any]: The URL to the submission """ - return {"uid": uid, "pid": pid} + + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if uid_operator != uid: + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + submission = m_submissions() + + # User + user = session.get(m_users, uid) + if user is None: + return {"message": f"User {uid} not found"}, 404 + submission.uid = uid + + # Project + project = session.get(m_projects, pid) + if project is None: + return {"message": f"Project {pid} not found"}, 404 + submission.project_id = pid + + # Grading + if "grading" in request.form: + grading = request.form["grading"] + if grading < 0 or grading > 20: + return {} + submission.grading = grading + + # Submission time + submission.submission_time = datetime.now() + + # Submission path + # get the files and store them + submission.submission_path = "/tbd" + + # Submission status + submission.submission_status = False + + session.add(submission) + session.commit() + return {"submission": f"/submissions/{submission.submission_id}"}, 201 + except Exception: + session.rollback() + return {"message": f"An error occurred while creating a new submission for user {uid} in project {pid}"}, 500 submissions_bp.add_url_rule( "/submissions//", @@ -43,32 +125,84 @@ def post(self, uid: int, pid: int) -> dict[str, int]: class Submission(Resource): """API endpoint for the submission""" - def get(self, uid: int, pid: int, sid: int) -> dict[str, int]: + def get(self, sid: int) -> dict[str, any]: """Get the submission given an submission ID Args: - uid (int): User ID - pid (int): Project ID sid (int): Submission ID Returns: - dict[str, int]: The submission + dict[str, any]: The submission """ - return {"uid": uid, "pid": pid, "sid": sid} - def delete(self, uid: int, pid: int, sid: int) -> dict[str, int]: + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Get the submission + submission = session.get(m_submissions, sid) + if submission is None: + return {"message": f"Submission {sid} not found"}, 404 + + return { + "submission_id": submission.submission_id, + "uid": submission.uid, + "project_id": submission.project_id, + "grading": submission.grading, + "submission_time": submission.submission_time, + "submission_path": submission.submission_path, + "submission_status": submission.submission_status + } + except Exception: + return {"message": f"An error occurred while fetching submission {sid}"}, 500 + + def delete(self, sid: int) -> dict[str, any]: """Delete a submission given an submission ID Args: - uid (int): User ID - pid (int): Project ID sid (int): Submission ID Returns: - dict[str, int]: Empty + dict[str, any]: Empty """ - return {"uid": uid, "pid": pid, "sid": sid} + + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not operator.is_admin: + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Check if the submission exists + submission = session.get(m_submissions, sid) + if submission is None: + return {"message": f"Submission {sid} not found"}, 404 + + # Delete the submission + session.delete(submission) + session.commit() + return {"message": f"Submission {sid} deleted"} + except Exception: + db.session.rollback() + return {"message": f"An error occurred while deleting submission {sid}"}, 500 submissions_bp.add_url_rule( - "/submissions///", + "/submissions/", view_func=Submission.as_view("submission")) From c857aeec417db666b091dab41bfd232cbee20d65 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 22:48:41 +0100 Subject: [PATCH 029/377] #15 - Adding patch method so teachers can update the grade --- backend/project/endpoints/submissions.py | 47 +++++++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 631ec927..26c13980 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -98,7 +98,7 @@ def post(self, uid: str, pid: int) -> dict[str, any]: if "grading" in request.form: grading = request.form["grading"] if grading < 0 or grading > 20: - return {} + return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 submission.grading = grading # Submission time @@ -166,6 +166,49 @@ def get(self, sid: int) -> dict[str, any]: except Exception: return {"message": f"An error occurred while fetching submission {sid}"}, 500 + def patch(self, sid:int) -> dict[str, any]: + """Update some fields of a submission given a submission ID + + Args: + sid (int): Submission ID + + Returns: + dict[str, any]: A message + """ + + # Authentication + # uid_operator = 0 + # if uid_operator is None: + # return {"message": "Not logged in"}, 401 + + try: + with db.session() as session: + # Authorization + # operator = session.get(m_users, uid_operator) + # if operator is None: + # return {"message": f"User {uid_operator} not found"}, 404 + # if not operator.is_teacher: + # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + + # Get the submission + submission = session.get(m_submissions, sid) + if submission is None: + return {"message": f"Submission {sid} not found"}, 404 + + # Update the grading field (its the only field that a teacher can update) + if "grading" in request.form: + grading = request.form["grading"] + if grading < 0 or grading > 20: + return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 + submission.grading = grading + + # Save the submission + session.commit() + return {"message": "Submission {sid} updated"} + except Exception: + session.rollback() + return {"message": f"An error occurred while patching submission {sid}"}, 500 + def delete(self, sid: int) -> dict[str, any]: """Delete a submission given an submission ID @@ -173,7 +216,7 @@ def delete(self, sid: int) -> dict[str, any]: sid (int): Submission ID Returns: - dict[str, any]: Empty + dict[str, any]: A message """ # Authentication From d2f4b706c7eea1c4e7003c86a61a72984c8618a5 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 25 Feb 2024 23:07:41 +0100 Subject: [PATCH 030/377] #15 - Fixed linter, only 'broad-exception-caught' but dont have a solution right now --- backend/project/endpoints/submissions.py | 34 +++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 26c13980..caf8b5b2 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -36,7 +36,9 @@ def get(self, uid: str, pid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Check user user = session.get(m_users, uid) @@ -53,7 +55,8 @@ def get(self, uid: str, pid: int) -> dict[str, any]: submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] return {"submissions": submissions_urls} except Exception: - return {"message": f"An error occurred while fetching the submissions from user {uid} for project {pid}"}, 500 + return {"message": f"An error occurred while fetching the submissions " + f"from user {uid} for project {pid}"}, 500 def post(self, uid: str, pid: int) -> dict[str, any]: """Post a new submission to a project @@ -78,7 +81,9 @@ def post(self, uid: str, pid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if uid_operator != uid: - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 submission = m_submissions() @@ -98,7 +103,9 @@ def post(self, uid: str, pid: int) -> dict[str, any]: if "grading" in request.form: grading = request.form["grading"] if grading < 0 or grading > 20: - return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 + return { + "message": "The submission must have a 'grading' in between 0-20" + }, 400 submission.grading = grading # Submission time @@ -116,7 +123,8 @@ def post(self, uid: str, pid: int) -> dict[str, any]: return {"submission": f"/submissions/{submission.submission_id}"}, 201 except Exception: session.rollback() - return {"message": f"An error occurred while creating a new submission for user {uid} in project {pid}"}, 500 + return {"message": f"An error occurred while creating a new submission " + f"for user {uid} in project {pid}"}, 500 submissions_bp.add_url_rule( "/submissions//", @@ -147,7 +155,9 @@ def get(self, sid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Get the submission submission = session.get(m_submissions, sid) @@ -188,7 +198,9 @@ def patch(self, sid:int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not operator.is_teacher: - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Get the submission submission = session.get(m_submissions, sid) @@ -199,7 +211,9 @@ def patch(self, sid:int) -> dict[str, any]: if "grading" in request.form: grading = request.form["grading"] if grading < 0 or grading > 20: - return {"message": "The submission must have a 'grading' in between 0 and 20"}, 400 + return { + "message": "The submission must have a 'grading' in between 0-20" + }, 400 submission.grading = grading # Save the submission @@ -231,7 +245,9 @@ def delete(self, sid: int) -> dict[str, any]: # if operator is None: # return {"message": f"User {uid_operator} not found"}, 404 # if not operator.is_admin: - # return {"message": f"User {uid_operator} does not have the correct rights"}, 403 + # return { + # "message": f"User {uid_operator} does not have the correct rights" + # }, 403 # Check if the submission exists submission = session.get(m_submissions, sid) From 0aad08f83bb1c1b696737c91afdf659397f025b7 Mon Sep 17 00:00:00 2001 From: warre Date: Mon, 26 Feb 2024 17:38:00 +0100 Subject: [PATCH 031/377] requested changes --- backend/project/__init__.py | 4 +- backend/project/endpoints/users.py | 31 +++--- backend/pylintrc | 6 +- backend/tests/endpoints/conftest.py | 25 +++++ backend/tests/endpoints/user_test.py | 142 +++++++++++++-------------- 5 files changed, 109 insertions(+), 99 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index e02f989d..17f524fd 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -14,8 +14,8 @@ def create_app(): Returns: Flask -- A Flask application instance """ - from .endpoints.index.index import index_bp - from .endpoints.users import users_bp + from .endpoints.index.index import index_bp # pylint: disable=import-outside-toplevel + from .endpoints.users import users_bp # pylint: disable=import-outside-toplevel app = Flask(__name__) app.register_blueprint(index_bp) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 915bdc82..1c48008b 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -1,8 +1,8 @@ """Users api endpoint""" from flask import Blueprint, request, jsonify from flask_restful import Resource, Api -from project import db -from project.models.users import Users as UserModel +from project import db # pylint: disable=import-error ; there is no error +from project.models.users import Users as UserModel # pylint: disable=import-error ; there is no error users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -25,9 +25,6 @@ def post(self): This function will respond to post requests made to /users. It should create a new user and return a success message. """ - if not request.is_json: - return {"Message": "Unsupported Media Type. Expected JSON."}, 415 - uid = request.json.get('uid') is_teacher = request.json.get('is_teacher') is_admin = request.json.get('is_admin') @@ -46,28 +43,28 @@ def post(self): if user is not None: # bad request, error code could be 409 but is rarely used return {"Message": f"User {uid} already exists"}, 400 - # Code to create a new user in the database using the uid, is_teacher, and is_admin values + # Code to create a new user in the database using the uid, is_teacher, and is_admin new_user = UserModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) db.session.add(new_user) db.session.commit() - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught ; + # every exception should result in a rollback db.session.rollback() return {"Message": f"An error occurred while creating the user: {str(e)}"}, 500 return {"Message": "User created successfully!"}, 201 -users_api.add_resource(Users, "/users") - class User(Resource): """Api endpoint for the /users/{user_id} route""" + def get(self, user_id): """ This function will respond to GET requests made to /users/. It should return the user with the given user_id from the database. """ - user = db.session.get(UserModel,user_id) + user = db.session.get(UserModel, user_id) if user is None: return {"Message": "User not found!"}, 404 @@ -81,13 +78,10 @@ def patch(self, user_id): dict: A dictionary containing the message indicating the success or failure of the update. """ - if not request.is_json: - return {"Message": "Unsupported Media Type. Expected JSON."}, 415 - is_teacher = request.json.get('is_teacher') is_admin = request.json.get('is_admin') try: - user = db.session.get(UserModel,user_id) + user = db.session.get(UserModel, user_id) if user is None: return {"Message": "User not found!"}, 404 @@ -98,7 +92,8 @@ def patch(self, user_id): # Save the changes to the database db.session.commit() - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught ; + # every exception should result in a rollback db.session.rollback() return {"Message": f"An error occurred while patching the user: {str(e)}"}, 500 return {"Message": "User updated successfully!"} @@ -109,16 +104,18 @@ def delete(self, user_id): It should delete the user with the given user_id from the database. """ try: - user = db.session.get(UserModel,user_id) + user = db.session.get(UserModel, user_id) if user is None: return {"Message": "User not found!"}, 404 db.session.delete(user) db.session.commit() - except Exception as e: + except Exception as e: # pylint: disable=broad-exception-caught ; + # every exception should result in a rollback db.session.rollback() return {"Message": f"An error occurred while deleting the user: {str(e)}"}, 500 return {"Message": "User deleted successfully!"} +users_api.add_resource(Users, "/users") users_api.add_resource(User, "/users/") diff --git a/backend/pylintrc b/backend/pylintrc index ea4a6d93..00d05061 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -2,8 +2,7 @@ init-hook='import sys; sys.path.append(".")' [MESSAGES CONTROL] -disable=W0621, # Redefining name %r from outer scope (line %s) - C0415 # Import outside toplevel (needed to prevent circular imports in project/__init__.py) +disable=W0621 # Redefining name %r from outer scope (line %s) [test-files:*_test.py] @@ -12,5 +11,4 @@ disable= [modules:project/modules/*] disable= - R0903, # Too few public methods (modules don't require us to have public methods) - C0415 # Import outside toplevel + R0903 # Too few public methods (modules don't require us to have public methods) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 20c6961a..dd2891ed 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,11 +1,36 @@ """ Configuration for pytest, Flask, and the test client.""" import pytest from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project import db +from project.models.users import Users from tests import db_url +engine = create_engine(db_url) +Session = sessionmaker(bind=engine) +@pytest.fixture +def user_db_session(): + """Create a new database session for the user tests. + After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) + session = Session() + session.add_all( + [Users(uid="del", is_admin=False, is_teacher=True), + Users(uid="pat", is_admin=False, is_teacher=True), + Users(uid="u_get", is_admin=False, is_teacher=True) + ] + ) + session.commit() + yield session + session.rollback() + session.close() + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() + @pytest.fixture def app(): diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 3acd8c81..6bbd26db 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -7,88 +7,78 @@ - test_patch_user: Tests user update functionality and error handling for updating non-existent user. """ -def test_post_delete_user(client): - """Test whether the users page is accessible""" - response = client.post("/users", json={ - 'uid': 'del', - 'is_teacher': True, - 'is_admin': False - }) - assert response.status_code == 201 or response.status_code == 400 # already present - # Delete the user - response = client.delete("/users/del") - assert response.status_code == 200 - assert response.json == {"Message": "User deleted successfully!"} +class TestUserEndpoint: + """Class to test user management endpoints.""" - # Try to delete the user again - response = client.delete("/users/del") - assert response.status_code == 404 - # a test that should fail - response = client.post("/users", json={ - 'uid': '12', - 'is_student': True, # wrong field name - 'is_admin': False - }) - assert response.status_code == 400 + def test_delete_user(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test deleting a user.""" + # Delete the user + response = client.delete("/users/del") + assert response.status_code == 200 + assert response.json == {"Message": "User deleted successfully!"} - # Send a request with a media type that's not JSON - response = client.post("/users", data={ - 'uid': '12', - 'is_teacher': True, - 'is_admin': False - }) - assert response.status_code == 415 + def test_delete_not_present(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test deleting a user that does not exist.""" + response = client.delete("/users/non") + assert response.status_code == 404 -def test_get_users(client): - """Test the get method of the Users class""" - response = client.get("/users") - assert response.status_code == 200 - # Check that the response is a list (even if it's empty) - assert isinstance(response.json, list) + def test_wrong_form_post(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test posting with a wrong form.""" + response = client.post("/users", json={ + 'uid': '12', + 'is_student': True, # wrong field name + 'is_admin': False + }) + assert response.status_code == 400 - response = client.post("/users", json={ - 'uid': 'u_get', - 'is_teacher': True, - 'is_admin': False - }) - assert response.status_code == 201 or response.status_code == 400 - response = client.get("users/u_get") - assert response.status_code == 200 - assert response.json == { - 'uid': 'u_get', - 'is_teacher': True, - 'is_admin': False - } + def test_wrong_datatype_post(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test posting with a wrong data type.""" + response = client.post("/users", data={ + 'uid': '12', + 'is_teacher': True, + 'is_admin': False + }) + assert response.status_code == 415 + def test_get_all_users(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test getting all users.""" + response = client.get("/users") + assert response.status_code == 200 + # Check that the response is a list (even if it's empty) + assert isinstance(response.json, list) -def test_patch_user(client): - """Test the update method of the Users class""" - # First, create a user to update - client.post("/users", json={ - 'uid': 'pat', - 'is_teacher': True, - 'is_admin': False - }) + def test_get_one_user(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test getting a single user.""" + response = client.get("users/u_get") + assert response.status_code == 200 + assert response.json == { + 'uid': 'u_get', + 'is_teacher': True, + 'is_admin': False + } - # Then, update the user - response = client.patch("/users/pat", json={ - 'is_teacher': False, - 'is_admin': True - }) - assert response.status_code == 200 - assert response.json == {"Message": "User updated successfully!"} + def test_patch_user(self, client, user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test updating a user.""" + response = client.patch("/users/pat", json={ + 'is_teacher': False, + 'is_admin': True + }) + assert response.status_code == 200 + assert response.json == {"Message": "User updated successfully!"} - # Try to update a non-existent user - response = client.patch("/users/non", json={ - 'is_teacher': False, - 'is_admin': True - }) - assert response.status_code == 404 + def test_patch_non_existent(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test updating a non-existent user.""" + response = client.patch("/users/non", json={ + 'is_teacher': False, + 'is_admin': True + }) + assert response.status_code == 404 - # Send a request with a media type that's not JSON - response = client.post("/users", data={ - 'uid': '12', - 'is_teacher': True, - 'is_admin': False - }) - assert response.status_code == 415 + def test_patch_non_json(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + """Test sending a non-JSON patch request.""" + response = client.post("/users", data={ + 'uid': '12', + 'is_teacher': True, + 'is_admin': False + }) + assert response.status_code == 415 From 5f4a6123092be55bed149d645c29828b28d3513c Mon Sep 17 00:00:00 2001 From: warre Date: Mon, 26 Feb 2024 17:48:32 +0100 Subject: [PATCH 032/377] linter complaints --- backend/tests/endpoints/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index dd2891ed..c72a20c4 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -26,7 +26,6 @@ def user_db_session(): yield session session.rollback() session.close() - # Truncate all tables for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() From 01bd8a811520d781e8341879d89008c6f30f7d05 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Mon, 26 Feb 2024 18:37:03 +0100 Subject: [PATCH 033/377] #15 - Updating the OpenAPI specifications --- .../endpoints/index/OpenAPI_Object.json | 373 +++++++++++++++++- backend/project/endpoints/submissions.py | 9 +- 2 files changed, 376 insertions(+), 6 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 7243ff59..7b855bbd 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -41,5 +41,376 @@ } ] }, - "paths": [] + "paths": { + "/submissions/{uid}/{pid}": { + "get": { + "summary": "Get all submissions from a user for a project", + "responses": { + "200": { + "description": "A list of submission URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submissions": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "post": { + "summary": "Post a new submission to a project", + "requestBody": { + "description": "Grading", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "int", + "minimum": 0, + "maximum": 20 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The newly created submission URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submission": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "A 'bad data field' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "uid", + "in": "path", + "description": "User ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "pid", + "in": "path", + "description": "Project ID", + "required": true, + "schema": { + "type": "int" + } + } + ] + }, + "/submissions/{sid}": { + "get": { + "summary": "Get the submission", + "responses": { + "200": { + "description": "The submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "submission_id": { + "type": "int" + }, + "uid": { + "type": "string" + }, + "project_id": { + "type": "int" + }, + "grading": { + "type": "int" + }, + "submission_time": { + "type": "string" + }, + "submission_path": { + "type": "string" + }, + "submission_status": { + "type": "int" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "patch": { + "summary": "Update the submission", + "requestBody": { + "description": "The submission data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "int", + "minimum": 0, + "maximum": 20 + } + } + } + } + } + }, + "responses": { + "200": { + "description": "A 'submission updated' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "A 'bad data field' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "summary": "Delete the submission", + "responses": { + "200": { + "description": "A 'submission deleted' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "A 'not found' message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "An error message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "sid", + "in": "path", + "description": "Submission ID", + "required": true, + "schema": { + "type": "int" + } + } + ] + } + } } diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index caf8b5b2..f6c4e4d8 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -126,10 +126,6 @@ def post(self, uid: str, pid: int) -> dict[str, any]: return {"message": f"An error occurred while creating a new submission " f"for user {uid} in project {pid}"}, 500 -submissions_bp.add_url_rule( - "/submissions//", - view_func=Submissions.as_view("submissions")) - class Submission(Resource): """API endpoint for the submission""" @@ -218,7 +214,7 @@ def patch(self, sid:int) -> dict[str, any]: # Save the submission session.commit() - return {"message": "Submission {sid} updated"} + return {"message": f"Submission {sid} updated"} except Exception: session.rollback() return {"message": f"An error occurred while patching submission {sid}"}, 500 @@ -262,6 +258,9 @@ def delete(self, sid: int) -> dict[str, any]: db.session.rollback() return {"message": f"An error occurred while deleting submission {sid}"}, 500 +submissions_bp.add_url_rule( + "/submissions//", + view_func=Submissions.as_view("submissions")) submissions_bp.add_url_rule( "/submissions/", view_func=Submission.as_view("submission")) From 461a808c0271afb1dae7a198f7ee22bf8645e74d Mon Sep 17 00:00:00 2001 From: warre Date: Tue, 27 Feb 2024 22:10:50 +0100 Subject: [PATCH 034/377] added usecases to git --- usecases/edit_links.txt | 3 +++ usecases/usecase_student.pdf | Bin 0 -> 19115 bytes usecases/usecase_teacher_admin.pdf | Bin 0 -> 25563 bytes 3 files changed, 3 insertions(+) create mode 100644 usecases/edit_links.txt create mode 100644 usecases/usecase_student.pdf create mode 100644 usecases/usecase_teacher_admin.pdf diff --git a/usecases/edit_links.txt b/usecases/edit_links.txt new file mode 100644 index 00000000..d83b589b --- /dev/null +++ b/usecases/edit_links.txt @@ -0,0 +1,3 @@ +Student edit link: https://lucid.app/lucidchart/3c9eb437-dbcf-4d42-879d-80fa55b52ed6/edit?viewport_loc=-682%2C-251%2C1891%2C896%2C0_0&invitationId=inv_d4c3257f-edfa-4a59-973a-310ca62f84d2 + +Teacher/admin edit link: https://lucid.app/lucidchart/cf630b96-4f49-4153-8405-75ea7dd9dbb2/edit?viewport_loc=-187%2C364%2C2125%2C1107%2C0_0&invitationId=inv_f2ab6115-7288-4b6e-b546-9e19638fb580 diff --git a/usecases/usecase_student.pdf b/usecases/usecase_student.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9640452ac315478189df6ff0c048cc7e83dc931a GIT binary patch literal 19115 zcmbun1za4<(l?9;4Uk|70Tu`@%PvlEhhV{-;O>jNdvJmi+}(q_OM-il;1WD&-X%Hb z+?;!#?|Hxb9+uzkOi$Int9z=et9quILQYte0mR6TO3~@v>C@@k>4ypeFafOf%uu6RhCn%8V?%qOkhOyq1ONiQHZ`!RO^YrygiYN1k%nwT_j4qv*Q`#i0o0uL#;l z3PYIfGV)viSJ_%@a?!cejtsgi)>=%;^+4g$q7!xR#zkR$HDEGWQDdsNuaZ~QK za<>5VS|xE1whlU9aSZvPEq3qFvg+h@8~J1I?r!DB;h5L=e7?iLl^>p$m|nMC*Ih^V zXEUohLHJi&KLVH9eq3JH>&S|_*@9D19Ie-F@~vOpd8F<0dP?4UMBjKWJk$SbcE{N0 z^2KZYsBOn#R(2=wiwcbFz*lR#Vogyu9}6Lx6+~`<(SDa3wJJe zU3_RU_f9R>V@sic+aGPWrk67gR^PXH{C~K<)7<5Ix7W6B1$JXb>A}cpScU6pdAY{@ z%K0uIax`Ze&cAF+g%9lIB30y{W`G-6*XR%DC-ORVi@H0(1CuA{sx|1jZ}l8}d(SRx z_K4f=YhMy932y3mWD-L=O{;YhLZUm;Mv%~AGxqcKC8N;lcVj{AVO^sb%`6RXdW%Kd zXYErX2v1)Wp%6LpF>Qxo4Ri6eC~xa zo(qt`3IzBb1|L0@PXR`$yW0#Z&=@%&7LG*Zu<2!0eIk2e(OZr7EdCZLs1`QtDfe?j zPq2{OZZ|H@e2pNPwuy?kN-!KUfe%bcP<2un6P=6vc%a)fDZ{jDVti^P1A2a6Qw5H| zG%|Yc;JXftJ*qj60gh&LkRQ%hx8ZP7fA2C?#5R`F&abO0t!pLC;X2;{E~WxyRrh^F zq;oOls3YU&c~|J&uTUcL+~193w!3_1!gbnlOy8`>vc>`M*v6-xXPT-!vwI$__R7Yp zGt-}{*;&&KUA7g`imrwaa~!*Wj9xBUHN&P5+d8v=93OpFtvzC24B4eI^~^(A$H}+* zITUMHPTH8Fq0kz3iE@yB3B6-t zUHe!O{Vr7yoW#VgE*Nl^cJQi5kDnj*+CRYk9FIib2lE|Tt-i!|@P}dSZ7dn5Wod81 zNogz{X1vtg+fkva#Sd36;z_N*b=_Y^grD}tc(zmP=8q%L^Mb!WaRD&ZW^_*$O1B?y z87<7W9e<6p!kzy-{uI9i72%2S0NfZZjKD}LNH?&#BWt85pdTVE7KKcQ-;mFQBVnQ} zrs7Undi_BKqT1dnLl~OFq|fi^^UYYneZH_yyp%)esd5W989r*ZcXN+!>6G!y2|zJ* z8BKC=W%=8*Ok0VjC7XyPx%I`8d8--fi?Nb7B^o8VAy${~{HK~-b9_E=o=@2APV-A{ zO&+9v1krz1JEyAJ-I0Oq9oXD9?8~`HRr5U>A{l`aBlR z|H?Q9Q(_~C@r@~5U*1NJz>IT+ZoE2?O~)(p!xFPEp5O%SVhMR!j3Wr-dk-M0M5~v@ zvC30wzfChb+u3)@rIyYxL7^oV|IJa2493Llpb-?!ZDSX%7 z`O~lVO_+_v*elWzCX?va^=l)`4Ug=$8MlvUx1}|ThA(BjaP7-%iZXW6rm+ta>`5n~;pbF!7seyr4|M;c?t~l!}O1B2Z ztVyM&h@;G{rqNdTDOlwP<7dd{mkgAgNm1=H=#Nnu5ve&Pt&xCsaaKf{Z4{JrR#$lS zRcx}F!v1KOm2|mdz9Qbm)Gyej@8>_2b>GVFnDff&tOvxgz2f%-j(acX)TG@qhA0ga z`Ca@VtVJxiHat|lH6J9Z7C9sc72cr13&|PE5*H@clS|Blp_R$4rR{(1!NhJ7AgsY%>d5mj+ag=QiX` z!-&WABNF)X_s=}S)rJ#@_HWP&Obx2=ettnFdRp{^Ur)E@!yH*6nP}RXe3gs#hUso) zD&f;fIX9To*-(sdCHG456)7!Q2>z`tZr0o802x>wyu_CJs%di@N8n{)hl{a?=EC*a zEvra7CH+`L)0aIl++~6A%hyl!KBnkqr>qj)1{GrbSdww`PrDe^OFqBz0POMSCo z@3wlEw)eF_x*m{=vfgeX#htQTkC=pM&4I^HhmtMXyNgpHE-p=jTKqNP38~Q zHs}uOr|f-vonRbCuduG?>2~K}HPr3A(W^N>=+I^%RcPMs^~$|S8eY^YSc=bD%#~Rf zZ37SIIibt3y!9-qplb-?fc~{hSHc~e-4UVH%T}dzZ+q5#H3SRP&;F+}5EVpIi*Z0i zgTog@3(Wj&n}n`_KX3eZPvN!Rj&h9r*?66sC#>tO$lh3sFJu}WYuo}Ah68xt#>V&_ z4*FE<$A5vD7PQgaB8K@0cN=~G_EC4EbCcm)v=3U`ytwRi-+9-FxoXW#^Iohv8@YSi z-+jFDkZKOf%`24T?+}iOEev#cZdDOFqidLsdJnNO&8gtERrJhI5f{Wnz4z{5Df1$5 z5I8<-V0W%myF~F^5+x?NKB23;W>c<{8IJi<*E4w0bzP(+tbum3jCjn{P4f9`(EKIy z9pBsPNxBk~ESqw2`mfI(>#w1&!#s&F2Jj!&*(_BM;;+e(83+%DJSC|tj!tM;WE6cR zsItvU(KXf}68b@h8?VrW)|i7rlROFuW0bIxe+0}*_|^pz6?u{AUesPcici@kQMB^M zDfEQDM2yk{6`c!KViUbH8}VJWS%4#Y2Gx228J&#-eVW(c889pRW7Er+xFiCd_tCz zr9@IPk|@uwjIMe9sW{wNIY0_a*4)OL{KOLN1c=@O+NfoW1Ys(LygFF)b4-4P3PHLl zM0d0(zLY*F$#OHt^pM*l6U+ijPn{s14ccHJzbCe%Mwkj3@%~^JBNyGr8ZShSm-dFl z?hxv$B@#66&T_+;88NQJi`wPCu~!qv5s-qmkq~smx=4&V=qn`0s=)4iWZ_3UuqobV=%& zwy>;;VI$`8mjj(X_StuD0p*~(b>~n{k`Yt_1%OC zMIU@IDU=1})Iu1>Z;B$7v4*_yM~n!xTr&A0alKMk;M{fNW0;lqvKB<`TH85zI8b>t z#;8d9kv#+AMp`e~mgW<=V*BvTq8u@j642#9FA)&Mcu^!3qkD?hf|FI12S0PtJj*T{ zcQ1Y>fH7LM1&YrPh6~u>NFphZ4b{4Srn#feN(uicp21$(BXc=J!Ec>~+9vR<_NcHU z1*|w6kd3za+992++s0&iRWr@_?q;rPP(lrb*Ap6e>3A*Bl(n0nbddX1dv83Cj}Huj z*@@@vGaH+`QZixi(=o6*QgZYBTr+RZYd&cup-6JE+!w1tdGkT|+|yOS{a!mCAEx?l z(Ra&Y{CITt>?s;=_F8p@QU&mZ$I51juak01hWxjPn1P!sgeEt~7SBfucLu&x)6bH} zUOF=$DsQcXnqG>Btu`gRIvg>IHa4O!km8^a`42!w#uwcy%>G^ z1E3L>r1XQ~FqJ)8P}SFf`{6^Fnab8m)NjVJ9|uk7-bmkBIG{Y)P%et98W}h3xeozP zhs|GZDIF1p&F9yhHwqM$+<38kDz)nLyOsOuvZQWRurFd-45R3yT@{ZWNUdm8MVb(< z;A(kU^m2lo?!_oRoM?G*)CNT6Y}kAIIo@9NVYP?e;4`j-IBw>gjUC2VDnUDEM z7J#%#Bb1tfjDgw<@}j>E77nn0SM~f5Q(Or4BvU&{rcIv%w5_W2U`CxD8`bF zDkmw*U*f#;ykT&x_lpJJeKHk3iSq5ytl%sSRKM^?$E6b_;OCVl4yr~@FxQR~&)Ile z!3-OGGJ<1=eRl=!%V)xq2ZwFgk-;DZ7=WX70wJ7V&-$IV%Z@il6pRR8)M7_k5H#4V zrY7)JBfQ>#IgHB1EyB+L3RhHMp=AsA_igcaDSjVeN&WhyS38zG#0QnD9NdS+3qD!% zMoARD5x;sxP81CmRT7i$EeM@XdVH4nW^^E3UOIMOq{rYpp_Vx!G3F};D%*nM!K#;! z8OJ{ppyLpy>{}PukKWwHU_R({*UFAa2GOSR0dt@Kj zqZobM!c=j|mZ$z>S-;3SZ;Du<^i7k%do4o^=Yk;rRN1ErbF#{b`MEWzeduqkU*Mnz ze8*ZI3AD2rfVUY65#fr%UwnnCwu{L(*-*QfOt0^~ZA45L(}H57hrsYX9K}VgKexcu z#u;;}zpdejQ?V+w5mYPWB|!bH)lFUdO5#&xA>s`{uJGlS`ZbgxWnrt1Kj)s8K|~*+g#QuZtG(%5)sLDiw-zJ-Z(y-ZTM1U3nR5~t z6{@D)!eq)-Xsl^jomB-W(5hOeLN0Nrt7K?<0US33{>}-b-5qur|2N4mMX|hY5 z`L6owXr%AHx(DlXni> ze~Pwh1UUjv^t#ho`gRNRHKuh=FsAdth=)1$TW#A{*5T5b)}@gmUM-8(d}nrT)nSvk z5mbsS>V&h?s8(igZ_4gVoY)E*6!eDzWL#v$U0N+cZVemQt1-DFJmc#WJ$t;2+E1RR zvGcsO%l94_oG2IjfdLyEpVg_)dYFF3*mjHLO|EidkI_J?(^!S!a^oHQz%eBLWh=S^&dk8E(z#UAR9+MEM&GF8bZ zRF8JoI^gw{mGFv!_bxn@`afP`c7|{5H|i0@e%BWLnu_NWV|cXQLP~s+NUP*>J=JjV zeLB+Zpw#E1xbs_*^;vQU9{Cw^hn3e27vfs^&Bh;^i3@K8@5_h__o6=@T5U&vWJ&sY zOkCLX`9Mof#(LqBXW_!*W?Xa+R*C22N&-W9emb9BwK1z>Fo$Zwfv?;1`-x|8UC+K} zu-JD}Uu*S}!ysyHCt1GkI`M-5g{xtk)_@#!F1s(MtCna36DuihtfLdWE4ra|H4R4P zqLjzm@w}I|3@?wEN~iH+@(Y&Us+8L4s*&NR=l9JjZPy~-c`0f2cGf(Wh{{z+a{>EV zEKw|lyxvrO<32k6$Qhr($>bQN(=pm3>oT`~_4NKr(8e#ptGQmrdcU;OvO_?ccTVh9tlL-|X?`|+FUwEuvLsV>>5LebvA6r6ZFdJ__ z2Wgh!U2oOFYhFoy`_#si{OIb3Tf@1`>a_OaF-i|nDG{pdeo*6E!!z^Gi9F_f-lI>; zxob&;jZnbVlBE}ZMOnU^5uWA>+tt?l!T!~lmE?KNzC~9&!|zP!H!8m{?II|}OBo3= z7zg&lUlk?#D9E+SVnSY8Prh*UJ<;7MApBOd7JtRxM{~gYu*P)BJs$l%KtSMqaj;vJ zA7fbPT;k_4x!UI`NCqo< zZ9_87Id?LS+<YuGVmPiK`V4EY4Nsv^Bu%E`!-c+dd>P0;b&dlVmj(se` z?F_ful1Dr}xlUW4z>#FhvlIrq$`EFH;TUxmBbGM8q~}DWHc7Sc`kQDQ{YA0h@X&`l z%t{U6T|pG9kdX*y;fl&R*^p@Soo8)7skiRUiW0+_X>-&Ax@fG_RG6t3oTCQcPy(lVG!ZYr8V_hU3*{_9nl+f>;Q$;R)E2H5Bb-3?n46xyK2o==J>U_hZW>o&evB+vZ zc!IGAVw8dci&V;hrZPK!8!Gw*fn-CmezXllyJMNJYjmH~e3(L2QR#3p2xkzPtv^(L zR3OFQ0>871vUOh}%faDb=4TvpWx17J@qUAp^vw8Ry}lc0=b!4{Bq;hV%L&f!nR?E?A2ybbrkiW#rK1$qxcsGER zD|82#*9WoGLAQrHFtIf|V_h(XVIOQIyLs$jMNhVa6C~!()(2GXEVb68`<%TN&8@kK z-uhQm*;Z?xux7j>Y(FRsU|&E4#jRoPoCvkd?SCRQPrr3n?glOYz1Gt+CN#BGJAs5Da(+?FwGyDzYepZamE4T&Ux;;A1`5PaOlYmGs*p? z;N8wbX6nuh$+whj67p8 zy$h(m@RNBzUdVbFq{_GS4s&V{>pRhoS&j--^`gWjvXGoDU$|tLNtEAE zh*Z2q-+}o%gIT{%a)jg|TndL%92_Y~BX484-@WdRqdE}BBfl^EsFfMeB9G~pl`G)l zjzyEoJT+;5^+J*#n|Jc~%Mj;y?tsSZ{;z%a3Z=K0~x|CLo&62yh16w!u-0{ za6^z$VU<18Ct_D&i!ysb8FlY+@u81eR2_T4CV{@LLw`BO^(f z=*qDOK1~7J$Qfz@Xy5DKn~lPHSLoI7_br6ESZWPJfxWH_7h397;)L7}Cp8T|O}Jo9z#9%MOJL2z3BI9k)V z&=qtau8w@Al5wNcEQVEojZttgO8;C$8C=T*es<0l^4)BjGa1W<_OYxN(pp5RXK;Ku zd#*R+*z?h`m4(>-deo76)Q>c^j!EH&Jel_60HaK>U;`s8WiFO)CZ!-+dN~=qSS`2Y z-G0v`eNv~|`k?T(ooN3{(J-|s<0`z&>*1Jk1yq?vG0h!-f_?gY^%$1ZVj_m?J5gB^ z@f!qd;AU--SxXx!v+#t?BurGK-+N@(k1sPacliaz<{#bMgR_e?y>lv%{p*^2V=>>T zZo1~HRKiqk)JZkVEa{gc;BMDnhjecZj{kM%9xXkI6#Umgq2=jJ*g=S6C10jm`t^I6lt+ zQO3PaX{(-F-UhKsh0n^e?gu>?E#Is0z4CDP|2X2!onH#$Bcb1OJ2nR1yW<&+hw-@` zZ%9$l|HhjCeSl<(RIY>K`D7t{i-158s(Xzu9yM<@(z>XI_VyTL7`n<3p+qA$ zYNfPNCNe_8r_lvQFbz}@pWD1O-x%eA-9mY4)7zT3VhqY>9Qc7m(P%xDuxEI5MWX}Y zqhKj>sPAtQ@eC7bjgf$#3+UwF&1*?n(E+J`@OhdtQRLe(ikP0HjfsS&|H3VfCs>gR zYri5n8TZ>Pghut>IOY?H7%Dd|LA`3EF~&kxQy8|*G)EBbfTh#?nqs?4{+%phcXf`7Tv%4xhdNT} zJ*HAA{n20*Inz>$n(9oTkDkxrHyty3z+cf4%YR+7(73F;MW>G9`~rWu4$ zEAzcMy`0dZC-Pf0FLSnBa_tV_Q)+DQ9F&&WSgr22->=LEPQQ%zT2?LizRg>s$li_Z zY~at2qZUUWf9Iu$CQyT~24BH#=vRq93iq`?>l5Z(bOI5au!4lCvs$rUQ3|!fNvdBT z<=8=mJ~IB`V;n>X7Sp6@bVtZ?J&@Ylh?iZShXDhe=Qh&?GjR#=FiWuM%sShbIw6tt^NhWn^2{Jkyvy>KrFr z->{zz3St{QxT6M|Iy>!(sr9E%M%wka_jtyM^9|ZcbstiQuu&ObH%~o&n|Lzg{C=OU z@dT%6=UO@=!&OPE@-kEk(NYbCvV?&SaY&`M5mzDmcJyt+%ABn?EZ}YjZ=MS2$3z^K%EZTS5uB?BnurAmS)4zji1aB^ z2F%Q>TqUOv_Tgh?23Z4u{n=kAUNBT9frM%@dx!1J*q%~tp{*(vPH0$S{@D3Q_qcQ~ zZNKf~()_ErOPPyqg(HKNS93>hRBlP{nyZ|ufY$ra%O9MN()Qh`Zm9GSTVn6Noyin0 z4|rZmr0w@z2@)6Gs8QYEZ&L)lF5N#pYI%Q`V!AXv#GCCH7GyBLK;x*BPOOM%ju8e( zTq!ztoHgnXs$zN)nlUNC}>vgVms!$*kcSPlW31 zD)SJ}7wH}ofe~vd>}(lV6yo&{HgWb{LFD=2hxTZDFc($14fVs~er({X3w}6=17RXV zDf;u+&cRl=zDX5;Am0%k3>$>)OSfw%7h*>KxY=P!Twxrg>yk8JPWxoRk^$GG@V(50 zJp*nX3zJEYZ7KhmqyOuVt>&*f8_UNO%Evx8@V3pHijFx&)i$O^uchXuZCI*Oq5sSc(MEUm6cBx`M!`>QTpqdJ@?dL)gfH`v&r-jS9C+J@F= zUCQa#74d}VnX)stgtCD*&2{D%rh)UX2AMII&M6C*vZRCVR zp)p&{k&lmAWcGt!`fAFya5@m68lt`_ZV+Tw zMP>hEV>75Sd#W>jm4H3fmoVVnRGCU2dguC)V+-oPt^-w29ECx zOkjtT)khz6u2m(`wupqbYa6C74mJmYs^Pw2rAt43qVpj=^v0B4UMz|fUe&TSb*Bjy z0S`tC&+zCAG1?R<0Vk7)$uS_cYt}!rU00Wg50Czd*1Yt{J2v4WO^uwQd;}hSNjaV2 zqv}Na1-<12otn@Vv8wy@tQ5Wi5`x9K_Er*t+fux05`t!6_zv`0E068WIlSNHUHg~x z{)wk9*FGIWd`t~cB1YfEMeh%U^efwE=#dOEJa1;)vJUQ>jhyteEX|EPFQpc{n2)dG znTugG5`qr*wXI$9%cNhY9F&y#Yd6-I2G!{{E)?mEE2qs*&zqgFz}HM78s#VqI*rP4 z^0)5V`kWL7eqf0t3fn0uXs?Og z2rMywZqOYPh5gzLyT4pk7xuu}pwbdb`_)M+oI0vWjM}rpxUi$= zh^VQ3a>KLs_pd?g(b@F!=-bn6u<8iv-m}MY2|4c|HwZ8d+RIg1kfTkw_xsyDA0L&!JOOAjnt)cJO z`W>;B{4q%A>QxUZDT4+Aj%p=ynh*N3DBq$~`Juv&00(~W7exE|x>)`j+meGXSSgd} z!cnQcpTPzZnQL^9z)f^8R$QzmycNN|AM!@Pl=B(t+y#N@{S0kGwxDC4d6wfWE-73e zCWr_v6T#9IeJQ4#b0Gq;vh=W5z47ookVM^jTQJsXn@U8Kupv?laqL9=VgeKXzA6ZI zVR}8o-qZ*fb%0p4kf={yTFD>YI+!b7k!~jmdAGzF5hB1xP#yJooyx!)wv2Qa75w70 zB5Q;Ud35qf7do}hXYBz7#SNS&O369lnTDI-&0MF0F9I_T_?NS+8BpW_{6+C~eKZ&78p}HM1j|%4eJpPI+vE^d|3$7bsasnI65@nK#Jo2d! z1+za|-D5Hck5L&_QN*S9kRuiBr!Fz>Hmjwr{w z+b|*WbPA1pDD>FQZ->p%*2)l|`;dzyz4_Aut>) zSl$VH^V#0t^NY%-&ynqesk_v=`n|x*Sx!410kwp^Xmx+ZKylw#rZ$KOkaW4xUvJG+ zEX@b+J*;{8EV{jZA6V5@FrrV<%)i3%)`{&}V*SJ;skcvR-Ckt9ib3*FvuanS?{%j=n{L)CR4MU-fqHF5UzxQycJ>yB{a>J(6#5nU(rb z4QX}HBR=b$DUL~fYc}2I_!SFdd{eXfkFu>)Jz%?vNL{|NMJ$O+RhjQ==>xGQ?sk{T z({aMe4^-Z(jNGJ7*T0;=;*0`gh~<$bmouf5^ipaiL~|$zPy0VdfPZ|We)d-D0D(VD zI8YGp<=j2I3ptU4&okHYYu;e&_j@jOc{9zU@i>PUvrj^topOfsMbFr zHm0z6&7B5(na-H(%mp+M(Y6crg)2vRX^zRBndFtU>U?V&s2`2rNDAC%rm{0EBgi)z zEKJff;~Kj~3J82l zrNda`%ogzgEf!%tGhug{5vm;F6S68Q2KX&asrktlX z&zqc3#i)N;IX-nNTTYb0zuB&~`( zUlqlQBm}glr;5YY)e{4yfg#2yQq9RF?S5pRIwG_Mm-^_BkB15`gQz$)-MKSJimWQZgfU<{daenQ2cj^X8VTKhmHi8DZUf>xz7EyPh-gOp|y; z3foJa#8KMR!zwoMQBpfD>Ne7QYU9aAj@QghjNLIGwqwc}R;j-npM9zgaGJ28%o>PO zw6AR`t4r}Kc39GQ!tzL%a4U4=I&+an@hj%5G$|ah8VUOJFitL59ZWG&B#3W{%7o2} z4Yl36i^z8}626=5rLtm zN?FW9r!Qn(4g)5<4Wm;k%D0)uV=#!23LO|eh>yPrR|gE|g0in*?U3y>CpdjU<1Vk` z^L?0JrKs*z)(z1Ww@H4MOZ4O$vePBq{9?5eo&fnin7izm5fxvqeCuvgX^J<)+G zOYo`ll)NfgXUFPM?9;ft&4K|JQOSxJ!Wnw4G?j)-m4DEKmh7Xh~0_a3p^cNs>e z>D7`vHLJMZy;y0i#bf+Sr3uCJUN?OT>HVYHuWQvgK4OndRuG9=>-_`N4wG@wU80^N z`=$7~`bG$;F!fYWIzwJOqr_gjkKkpEYcsO~>zoTK-8I>`3+@gD74fR3Id-U--{s7Y zFx)K_og3}lTF;ixJ^B>Y7xzlR^zi`QNu9j}avutXw@oS!&!}93baeu`(2Dy)4tZsw zX|iTgZPb%l{L=5Zr6wO8-=mxR67}gV+dFrDxhu&m^`{R{+UlcsNuVlQx}isr*MEUl zhD8A5C6WJj8q2Awyn{f@+*?S)hSp6W=!ikLBWB%)S{%c_W5k@x{?1neWYXZ^+D4RI zX?)a zH4n1GvMM`cI8~K0_e4)$-ulwm!7c+1ohrxd zM#iD3o|BV=0#!j2RA*}#9C&}&x8E$_syguQ>vWC`ij+@jm#k!D%J)9fwD`^U3$=AJ zj~Qbh1=pcZ95w~s%Z2!sjIWx%ZeX`>7l+ejdhD|R(iZfdB7l9O)%QZz>#n9ebVjLE=`3dcFuq3-Wv1A0LeKpp zZ3>#eR(Z-EKbXUFm)MrEPp->KQB#V?{*G#G%oe+ht@E#$pWfk_HZD zw48`D4s9d{aDbKum*~@E$^m`9X9?V#6!_z^%WJ?VEsf*dJA+4%SzHf?OH6(PV#=;n z_&dy4>Bi^(IcGlP1pSdVByVVM?O>;GXb%8CBnm+@i0lj>l81htDC+9j0~Mh8L{<=J zp}odK?vOm-VaAo$0!kYinCc2zJ8LjO&pDXc8Cd|#oNSCNTK`H#0t#4JSws1LOFv?R z68}-Ppsu~4C=?nfZfN0X2r<>y1u8h`K_2p$9+H_rzi@t1h*&`rl1#0Pfhwj}0#^2> zf1dvsQV=oR=M=oR=M=oR=M=oR=M=oR=M z=oR=M=oR=M=oR=M=+$qxNL5zP%upY~&CSaTl(VzeS1^QV0HF!3Kt)4m2vEWjnz$qqFOz-m&4!2=H?t{+$%s{GTfK_fjwb3j7}cEP$V*;_swTp?=zie=CKi8$ST>LjV3uNe2IB zeuSV2#}HF%D`DuwL@mq(W&%T#lfmp@5ECbeg9fN*3b8PxR!b+c!=3!QTP`qCk^-Xl`AT+?Al~~(RtLWN6>7e|M z*4FmW^4EqgPS$n?_S7^$0SAZ)l!68*ZEf&B%knSzK}^5dF&O|m2>4fKH3#^ASc&-M z*gyw_0>lz}+0GsUU}fQi;{BCwou>NQdO;Mc^;BiJQUr7U5*`^2M|weMzP%z=+xG>I zi)D4Zh(J#2=E|kUVcAP%Pa6-c1Ty2m(-BYRS3)$w(QyWx5+a%eG06?JFELQE#n)(; zp|4BjQc=915gkO6Pp#=B>qn-K30KI$QA?CdE@93==fp65ovv%UksVAm`W6+F3$UfJU|vs;bdD0Z@@F{O$3R@UTMPNoK*gzsQvIUac_uiBi}e7 zHwz*Yu?a8>jOvTnaPL3sCS;TtLf3trBO=GW&pF!Pl@8B{=|-Fh92wz96fg?fx(egzz9hrFvY4`+F4BUau|4fBlbk;TG6 z<$-q&v*`Xhv*#l=_yyKMOo(sIV7IeO*F~fvp1;U58SA#q%A)-s%S5I_2f8jP+*%E_ zzRg`Yx*lg=5}M_ySzVZff3xGmW%BN%vP4l>4L4?P?hS82gNj&VfeLe_Hu3D4XnWX%af}C z=gGo>-Y~68r^ULySfM@j*&>rO(92lb*V{yMz})KG?H1A1H6_qOD7+9E_IF;TCJNf?j~;$Kf_8o zn>2ZNofKrO6&YsHNU?;3P^N|S=H?b9d_<94_7lg7Vr_!PR--Q@5FQh)^Ptq1 zmO9lAjkW4Mt{uJl~s@LGPfJrw=r^( z7AG538bN&T7rqyg@^Vx@`wDEb5_kM@S+y-#Kflst)$LRuTDT|s)P-36bR#jSn0skM zfRuf-F?Lv5?NIk*E?G8LliEIaKqApJm7V3$-fO$>T20MM3`h3v6D^oGH}}kheAxya zx1andGage%Y_TLkXryD~0>R0{>@21?G__rET(4d8u(Kyw$CR%R~o6p%sSKr(a z0?;#r0`riO0Za{e$W&ORnWSxm3{6bMUF{4NTxAsXUCs454aoR-QMsMDoGooEp;Z8! zEiJ6jMjF>KoBPDg<33S%Aff6$PPADz?zmr;80{@T_DyM-ym(fE!3*ptZv9W-zD?CUGg!&5Hz<*tW zcFV)x12N=5*LZ(+l7I!|*MWuUgOpqrx>m+KWX=o*hDN#$77#MtKV1)Q;6v*zOnHAD z{N#H;|AqA%xc-AfV{P{fRZ!RXe~16ONAho#{N#S9B zTYks*A*xR^M(KpaYnLfpWABm12j>QI>)x%^+b ze*!7rone^E}x*Y8KKb!P--oJxNSlL5#t@I85UqFB3{R#T(hWs|veh%WF8V4Q4 zP+I`}%NRgU{<0;|Ct^HgP7lW7Kc13-n0^Z%|Flz3Q~WC$__=M+A8UTnx_0IgRz}v) z(COjO&;S6jbAavz1pL#cL%Dv108-GgL2YFLb+Mro`Vd7sLqmCMYY2e(VMRb5sBCKJ zWN0U6XJ}*yT~dUuFhUW9P3>(gbX}k~;HO#r6Z+?zAq2IY7S_hU8`$5%RtaGZZZ>9S z7B=V?Gtfo(htZScXTd+>Yoo|5<4o%A~vP@%Crmk>112TWi7(yag;ClxmdRq`KZo6Dl zAN}~Nd8vzh30BgG9)Z(3wr%SM4Z0P?{E6s{I62J z=oX@(21<|H*;eMq)=UHo-?!6e#-CrIPkyT_Kb~9~oeWp~AKcYH@`)aAw?9vJwU>(b zz3OYP9_XhXDU?)ZpOT$;U4HhH#1h0k)M@6ze$DTHzzAWRbN&v!C>*aZ2Guh!=At`S zp~25#?LiyZlCN@1fcn$TcXUB)Z2>)!_@je$gS?jow&05ZKk{_zp`*<8c2T&m>zx{j zyN(q~v(};LfKQ`<#Wi!telH#-m_0l(m4fi0#&I9G(1K<#@vaA49$`4sc`d?H#@Tk? zL7X2%B)Vt8Q{&xoc}POlJnE@ z>d;>kt(eW$l1sq!i|q5U2A0eKYa>&{C9+jRH8~S$E|N*lA8w^v$&CZeR-~f^W?*p zCZcgHNz1SZclp_rm!8Wj`njAw4lBGMW=qt54LLLNwSPF5SrXkKI<*BPDs zjmZ%JP(K&+@Bm^X$3txDVX@5r0m@jI4NWnFP%K9xqxcR-7Caw=p9MK=vpZ6P1F>t8 z;`7=sxaC9Vpglc-0d%l1$C5g2Lwx&_E_!b3_Xu$qh}v_blVsFwj-7=}*w5I%W|(H4 zUSnkJ1}E}KTq)wi*uzV!2@o=2n82iTI!TN+;5$30}^&Nf(E(0&0Y2Cbpcw&YU3+W5OP`v8aQUQh(%{Q|*) zE^z>><_Thq>q$L9MG{OsnU-3BHn+^@ez`*4ZI^_2>~uQ8^h)2gOEAD#r0n5kpj6-1 z#8=iF6kpp@CiEhShn08k<&t6<@L#$i@vPcY(_rEZnd59DNQ;KFHS^XAM=cDr#G7DM z7{($Ci(*=mREmWU8!Zh#(Kz3dUlS$z7`7c_5gBJk|Du@l2918zQKNCG9u65APyQ8R zuj1f1w{&a)<2b!;1#D^xp2%oy?~6^`iH z7MIhU`xtU4Fgc7gU&Bz)4lw$lCfRuL`#Ju1b?poC|9#&5F}lQbEB!hs`569r!Q>p* zMB3YQj^xD>nLX|FvN98J<2e4AQ3XI(lR-)4kUiZvw-)mC8f6ax9y(9z9#Wcwr8M%N z6;rDj=Q{cxYm{=Am)C2a-G2KDbU(Z}i-9Fwagvh6_!1x^r$&bh(G77UBh#ukanK62 z{t34i65WYN&gVy6NCSU(hflIm>-m1QrXys6Oh534$U;mlPEayyg2j!G zSQuD@`kfedUwpIvsO)N*S&+eAKaV+kc(LuMGKr%c7H+QR&$hYK> z6@6$=yPAQt$kHYFg(m#e#YGyNIY;mPy(tiDpcCd`epXbtt11xdV4e7JAE{J3bc$67 zzolcU!QwPBa z@HOJ-iIG8Covx*w0QFr=T(|OPPQ8mAb z#a1UL>nwV47p=pv4lh`jR=aA9X7gU5wb`J2Q}QJ07 zKo>&y4c3v#H@M(+mNA*J8D)W_&a=N?5|+~{3ni1yXhcdwN#ed41LB zM5AOe6GD~$g4e_K*wKeh-rVT8)(#BtisXzEd)c+51VsId8&5=szIx#mi~`BHtzhW)HOI-JAQ_hig; zJ8vBLf%qAUYw@Gfzf5e|w@4^oqL-DGO}pek-*B?#v*mn-2yC1%q&1*%QE8od#X%c{ zAE~tzvRrfa!ZwTZD2$3x&lSpQBqWz#2W?_uPK9}rqpC7SXC+ghd4dG=R#4P*K}+pE zIgj55Jmo?i+bq42Z$pDsA1}%V2EjAWhp#12JbI^(|r3^FyP5qkJ=Y=?PjqT9QG%Im}NQ>-a*M^iDiNQA_7pak=VSsVBHTPdEVbS3CX@Y zGBd{QPn=Jb!wiOMoASp2bnCeAuF0J8sYwARP3lePLYoBC=_YRM*AV z#~JXfPmDO3xyjCAg#&1*+$izLg^`~74`KV=%ss?)zytE1bcgS zbu}>h3Q~Nzx_f5oi6s~6;f5t8C~?WdDzPp0rE4TTX7)Z*%ot|ubF54g7++MfWxP$S2ZzxfBEd2u$U*hiIkZe$%_!>MvUZj;CWhv?C31#kYn!rPmlQYUrG?^+ zV?BEl24aMuKN;0LMvtT&WW*DolY2OwZIq;_B}S36U`irT-2EJ3$pfM$VB<1^LRg04 zXK#53w;S8gc%>cs=3OIq#s)|xDGwf@3Yg^(=&vN%?<_)H)dB_CW3BvSeubCW1PLx4 zQSIeYQN#94H%v&4Ic@_hm4~8fNh%Wl2Y%+BIp#<2j{q;cq=5n)X)(t1a#+M$I9QU< z6e$2+^j4dp19P&$>JAR_Te=8Skq$gke=yGHj7qcgm@}2{m_WWiJfyxfdYbV5Ut~&J z`1A&G^J$Q1R4p@FM?^m($OcTri6vw^Wp!f0$+bW>$%VYgLr8Zcem-nWcv}{6Af`OB ze8w|uz82(F^0q9Q)ah2&$Y&pdoLQUXWVD3t^WWGkBbZ1b%-Wxh)?mibp|7&wXDwu4 z-q4&tAGn0oGSpiFYC4SPykU+{i~JK0e@096&(XENi@_YANA_3Z@9TB5ny%w{S}TjH z5-ZU%)drk(O>(ZHN&E;TCVev;u62vsYf~l*n*M@tP@Evb$krj?{TX{MdO>A}OMDUY z34A9g+zpqf5|_z+Q9buNB=9smvqZm9j&7GN4hiV@0{W0>&G$YiH;&(DQqiJ*+=`st z6F^ru5mwQfW<%6O-`CiX3k0o`KWGZtuiYoNRA zVi&}Ok)tYXv|p_}dLG_|mU#gyak?1#rAZ^SvLs$PIjt|+*4*h`$Oc2cR4=7IJWBbz z|5-{sdnxTX+JNHhrZ+6=iCV(+FaQ&ZSxSxn5COBx?}Wd`EoVe@LroD3yq93oYpn4y z+>+=S28831?i$t&&bJ4CEUA{C$uEv1lmP}J(bMk8O<4g_tfZX7#pXMW||7KK zk|-0S=u7d7SUAXt&)P@}PD-Rru}qvUE<%&-7mtiEobG`)1Sd?bIJL;%p%Aipkss38Wkk_d|R2A(viApqLw6}1Uo|K z!*s7Dd(w`VYi8BwCGp)z9&E!zdY9Di$_Y5?(pGDfYhkwmihp5OB5m3^7QjLJWrMUO z<@X85KmRu^>Wxm@Z%IP%8>aIE7&Dq`y#dCdIanz_K;%~5!pj^( z}H_;t7EbAH)uSSKlY9?QzszV-a&^@OKC z<`in&K37_zG+LDP_*b$cKAlct8rEkJ-_rKhpUT$&j6W<%7kk`4IB(eWM%Kg>9r;Z2XIjLmLp+C$Pp#0Z0PMyP_A*}W&}z>xClV{lRqWr1=mM-E0(^-fa23B3RHT-<9n_Vki!n`WlIjZL_=`Ta%Ef9DY^ZuIu*_i3m|+yXg-nK% zaq|50{}4W+?~@NWs`MSDdi+Hq;T`*@#ks19tCz*c1Cdx89aeymYGzoue!R-3=?nm> zXRs}qC(aZgLoiQNcmW=J!bOszMuNSs5JZx0=vS2aS>Yr7lmWlxr_0Wif1WmXL0lq3h|Mf9EA%Bp8MV zQS3Ynt?UCnT+C~w_u?D=)bWc?Cqa4uSjR2FGqT1CJggD}^K30y>D+`Tal#Y8Rgujo zX{o1crn~`F8R?7=p(B5viU8gZXp`Z6=Zhr!o*}FdR&91GOt17;vC{KBQVvb9%oFxf zz8x07NE%%rl_-IJ<}$?K^EUnC`5Hch5Bq~ke;1Z&)x=FLO?*(y7o7H(Aan?WrFL- zSh#;S)loN^?XB>Qx^zyD7SUa0Tsr0rLeDevv8cz}*K-8xHHZkFa9~PJvA70`v`gxH zy*Q_<0bv`05;zt^zc=b9A*hHfw_Z6lH9p16TZk(m-o^J#^2Yqc_zemwbkY-$1RcCU z(J%%kMjBy)KOc`V+q?`V?gDKc>&u}lAI4`Q1F!n?um_KLsOwemZSgDG}|0jmw=5RQxl{} zyeu-jO&aM^ij(tL<=>>#yVn<0qlp_^1yjkHQKt-3Otdks2a2r5(c++mdzezV8Ar#I z?GFdj_BQQhApm^uWU&)PmhE4Hk)geN`Awp(dov)5t-e6K)e>_)hwYm>!}OPRP|%6_ zM~)J{g&B^~Q)5yhP!fG!5(*4lLVRTqwPC6OR7@vKil%D#pbc3$kcG_e<(G37#PKw^ z*DU!8!e7bbfC=f~UM`LtCLom;-~w|6DqjK_TGNbp`2Zf9LrSbsEwtnEjABfL788NL z@DXs;fa;@6^v(1gZ+`>MjMt4xgua(KdE6M6RH{{nr?a}OHhd+VKXYh2p9+1 zq>Mo32mW$SDPre%jS9GZ&TGI{TK$DFCstNejybkqZpz6)84MAZ7?!5X^C{B_sNW(U;m#5M7h0btDz4 zi#4$y?M0g?fn?~NHi40j;un+OU`^a{qI8a!8b=f_3mJ##Bg{LbF|;w1W_r-6b+^zP{h~%rwW3y91hj&AMVx&!snj{7 z0?J3-(l*hT@P}aP(=1)SuU?Mc58^u>IFc;jJ9G<&3!Bb|`&3IE4gU(FDC|i>qzVj5WeW z)YvSLZkz2yJWaKmoGr#;nNKQRJ5(34eSB5toj(ud*IzhnH(WK0!sGI zd<@fcxf;gbMZ{fgJE55hKWUtZRNt8zuLuIdlih(6&Au%`9#ZD2#RTi|##RXaDIr9C zId+7L)s8%_30dKr3MEHyPtFSDj=9+2A1VnNCCcwO@gg674b*xPiF!$?{}fQ zZgX8sc)Z`x3XR`{m296;s%aFSqFUvScFbNJq+0dJJvjWgvQ}|3%EUS5DP^>WiFM3& zvou=6%(qjJJj*j<}&uzZM{rzKUWri_Z zhfZOd{z8$J?4Qph#Kc~Zas>~@S|I>iNnfxH9#<{U9L@R1{kuQaA+6iomQRSEKH3Hd zAu2EA3ObcJ5C_|A98biRkQmTJ$lIG_X14~92r_k7nwom9swUx)j+>Nvu3f35SJK!e zXYUhuE7j@U*4i{l8eL)sko3T=P-f}UPD<5$w+~xjtC(cV?NoNbUTjn(^f4+OH9E$8 zX<0T2b$0eSFY8=moC!<~(?W4=gyj58Braw0NV|p)(84ON4X@PZ2@?#C{>0$$J{|a` zVQ+dQne9#g}l}yjTcbueA~O^C7G#)ItxR2CVkJ)99gi3ic6DVC1CVe!WU|z*VIu!vR}Rof-A+|umPSzT3I5Mf0`B4A!W@~3s@Hm803$v zGS$E^wl7o#V2_UhU&6y83Z{!Z$t*`k)0G*SNQ*-#Zm`q)j~IDX@Ou%|k(qrZhAyd< z#0Yw`S2`jbJRCH?-|g$V)96X>$4RRXty+z}J8=BTy9(y;*HUzJ`4PJVRZeJYg~Ik6Z9WTrN1 zH^MIfF4ek6k(CMy}?g?(pl3Cu11t ziWrZY8kVgzK~?p07o!nZz$pC)|HKH(QS>d5T~&v~sEDr2x&{Kn30cWHqKOd|uF<34 zzo;X}!9dZuHEbND?TuSx`%g1k^e%9l4n4@pR?(C;~M~#|4>4K(GvrN&~2tpg= z8&`3i*z%(|R{S9kF~Pg2vn%{LNQ+d<8O2&FR#n?;{Yl@qTFr2H~(1Z}%%go@lV_F?SRH ztej6>svG)%&<>;@jm!s+b!7z;7a9GSb(yco1YXY~YwD{dLTJ7hnzGD}_* zVC$ZLl~+09kvO%}%Jf-6?S8;d%qCyngxO{^(*lqRCJ#3>DXKd|;H=5hcp3^UtEbHz$5?2`;2g0`c6O`mOmte9LkH9rM@z5XOK17WJm8S4iYk`(#n(P@*-^JQoyh7=Gf~vfml#a&G%{w z{AVjaee`?;T{+AlL^UYQA!O-^sU{9bU23}=kNMxEVr9Eb>ky{=9G33$U$7%?z)h_2 zJHPCxdnK>uj1Uqx%6lAzvoyY7^g$CgrXqu&AA1Fv`C1%m#6q2(|Q{t+-F{UZt@)hNvtJow^bEKK|Ny#HS#Y z4$2#sno{|T=3L+H`=p>Nak0}8wbbo~eeUTBGo!F8UeOykLC3H-A>7Y#y*8{E@q9R=Io7SdP5u?E;BuxfT>JHPy<%xFG@vsb!wy8%CYF>_D zO*B~a)bJ{c{cx%JNkBg5D+k@w9t+|t+M-^X>VZ~fF5s38f}U?zEbuWV|F|q;KECxZ zr$Sy#1)H=K5@G(w7qm%hW~LBXR<@e7~Y?4$m!`qdwT7vXG$xt!kTv#UXDAy z;(3|iyA@ND7!Ag;BGY$usm8yj8v-*Am`c*5Sw!28oqeY0-XUB9^#zH!Z9JMuH3lB> z*oK$E)x75wyRjVe$pEz!OR&!9|RWaoADE`)3dsedx%5!q1#v;u52*^g>4 z-1A`BRDg*}9PO|==iEU|&1|EMsGsYLy?|y%bZkp#IkkQZ3EE|oCaAWs3=(dUGm>g9 zn;_WoC2WPQxgM`|Q1${Je@K!@lEG1ysg4nl?gG1v6NS7hIF?u>{`Xu*oQ{n>Cxz&c660cJh6Pzju}puaD552e zP{Gyv*0Y99Sx7Xo8ek(u<3gL?=A%37r)yr9mb>jMkPqBQmi!-z;p9jL{V8xokSQjb zzlR!*Vo(hr48Wz(!#^T51H^ZM`)VE~7m3k?M7M#lt__ZX= zF{;0@tM`=QHYs{jwuYHw9^h!}a_y!zE0Eg-#*|O7tXLfVTDeuva-`_KA#L)J`}39_ znM=qPV8?&Sm{%RkIfChZmzZ2CWa#TF$=Oyop;D2`IQBZX_L7m+(jqx}{PI>44 z8eJU#sx^@+8(}%6dNR9l*uo!2zlc$lPekkl;Jyk@2ZfjQa`eL@-SjAZ9h2hTl9Z6C zO(ZZpc&-e2|G(@$2CeM1E`z6@$FQX~3zYqRsb20vpXk$&2)pKCP@#Ukg4M|O7co1} zCKevN-Wj{9XcaJt4%RlK5HVx3finnPDv{=S7_+Mgdlz4jFY4-z{2tfxm&hYtC?#kR zRb$fZV!CP(&em2iJ-7!10RK2A{=bNvlEfp7tLs1I{x2ciCMV#H^O#a>xR(mJ7S6)VBIJ-056 zbx?B?&Rq|Ia>^fg9nSi6eqzk^djui)@@O^0+~I9SZERpsdH6Y1xf;Bz!XieD+Gr#n z56T~knwmE}4-Y;cSx6B9Sh&Nwr&${w#{|#|u5d;N%e(Z$X$N|)N*qe&2p#l%RIATy zvz;|)@bzU;s{RMxcg1axoTvU{bCN5zF51pk_6mLz3)Y z`6KxNQ&0*acPG%Po)^ub7YX0hOizP@1?BQ8S68qNdamXku#g%0=$A_d{=XN2=K3k{ zPsh(-wgPwZqX&|N>lRnDjcw=jeZ2scXAHad>6Fyk6AjD_K~2{ePM4HuprA_&gTzP3y)}M|3UNdgpAQfy&dCe8EomQp8$d zU#6xYhfGnS;U?9xkpfkW(rTA-sF{{*q&P=@SKeiQ9tPe7H_N<>^;CQxZrY!72b53r zjTzizY-O?g0<`MnddlFD38jWUuEJ)k%rsnu(8E7PA%v+i{GsT~7TQ)|;|0p$O77)` z1F2iFN?AIWIi0$ctg7n zqrh=rVgR>TFENbA6OqAJTl3_v(f+Co?YT5}C*-f;bE|xy^aZ)OB<&Grisi@9a*E#> zxhR40zmJd=H)_#hY*1FW;t>DA??pPyxgcWAqd35Jo5Ps_S)ve8gSRm3CfDvvZyC z-E57wVK+(mZugFCy{*i`FOP9S?G!t@_gF)k_f{pjfK;+g`_0 z-}28ASL#kV&KHa*Ws=1hWfJ}AT0a|?;@Xqt+N+xSz~LK91j0?-{$8!l${JKeO75ph zynZ#BQ+=Zm_u0S1mAZ6r7fr=V&CC5S*lM&!s-mh@*G7djF&Go2ig7J1y6VI^vo$jr zM&ZjlAxMd{xO%*rPf$j47eyVX7qFYj!=8d5#UA!IuukoG_Rn%5CO34XzmobEV28cXEAuT55a}dx-vk`AeBqNZ`9_1NX$*e>;Z{H0t;h z?czkv_C5Gnj6sX>E8IFip<5mqCVyB%&!Eqa)Y1FA*(#o;71*Q+ACW;QxAZa@t9}8# zrVoX^*IN-gVrvhN3-td6<#rRVGQdWhRX-lY{1H-AK$8CvA(g6nsrwz``h*dvulm!JH&w`6}#DYBz7dsQNpYj|6wS-3M(C<{A<6B z>y~Yf(@#vwb<`6mDqt+=(^B;Bx7Wq8a)6U>`Sqs!o8$odo%S`O_hG+(gSslJ?qSeJ zM;3_()BbGnmgY}y&Ct0dmULlYfBwrII69nwmVa`#tNXPdlQ_;|P+b4pj%3B9@Wp9T znD<32)9P*|UldiSox|;8ZM4WYRBrY#twh1t7v&%Crld1Gw0+u%Zp!pAUYVsX>}7 zKsPq+o`=i&2&hE?$ie@J@kt)1@`@rBc>(|6e(HC^2bJ-A+mdjqE5y%0)Z@lzAirZR zUqNuMVMh6fMGku0zxL9UcNw;Djyxzn^k0F}B1w0B)L&Nq6kX9IeN@E9w`k7HC~}8T zED+h&JF<_^;kl$mq|%gmR)GG>jl(iO4Q%82$8NM~^CHyL%jxwww{)OoMFZVm`auHCRSF6EBs95O z)6^(OTJ;nbVOH^oi-gdfA{eHM%dFX+(EbHXFQU5`7KB$;9@B-XxViddute29GbxSGviltz4-uV_nGaQKg z=@~LIwalYz%gvgdXH8DI%HM%RWxqM(<2itxS#9Po`H~<+8+rJb9H)jy{0g{hH5_`# zj3-q#IBW>kGka!DOS)HESl49G`KCjXr@_TxHc;FuH%v0NaWv55y;dF7paN;k$HS2{ z%}@IcJ5H*A50gtSD(%Ysd|{O~P_`D?5_%6Z%VsIrlg3m!RspD1arVB%HEP6!4ZHmdFvBGGCD^ ztdK*Zf~6e*i=a`!W9Q-gmNP-(JA&kjFT$Q+eJ9IfR^O6=A`e`JzO9JI5(D>v;=y1e z!5L3mEbLMPV-+oyJuw35$VoCwIx3WJHgO)a;smiBx@4R7QW zv7xxZ2Q$sOSvW~b-nzf|YdlQ%+7Nm~@Nz2?O^;3W-B`}6&&OS%YkKUq>5ck9ubG@k z@#YAT^Jx65)c7Q?1|)hMwBJkUL2TMQkWpFLbE_Vr89S9T^m^T--$UC%hvXvvNZA>0 zxOIhR2XQVSH><&8-{P5A^_X zTnR(8UV6nfH@k>kWpqT;k}7de713(uXA0s9>9pLF*G!J`LMP z7rzA-+m}%G++i|BsS#Y2^*&CpEp>#aj0(G?8Koq$lDYk5@QZ{4n zRb_ai+0tnKdf5A*ct2oL1x`B57Q=}(yc)wPnF|FgA{R_L2*P4bCm zdgW$ro8=m4$4Hwv$DY_OH}X(LSMYpm{k(KP(m8AD;fIy4Oa&wAx)F|ni#ddanDaLL zc|AujVcrv05njCJ!Qd9V4JQkQNN&r^&PQAuzrWS4a5kOzW}CLB2bnM44cYPeT*9A- zQ6$w~WO?>tNCxxFGMTWF{MLF5tr1v7UAU)s%`Pi`s#5Nnx z#K3&K7zPr|!f+CURN!@C#WCv_yh>&GgNeQSAqBO~7pp4L=U#dY_AQQ#VJ`dt@+V`@xR}vsXJ-0r z4}gGVhu$&94*jD;?|uPGAbDS7>==j4F(1>l`{`F3@7N51o3XgmKV&(Se7v*sFZ`C@Q*^It5sD9ykhtr=*lY<2&&gm zQJ1bi25_sr_Uf7&>6W=ef>KE4zCvRN)RSw{c|Q{3iRDmg(E4GUE-Uz|Ky*n}qdF!r z2_BN$SRZ2rR>G~sWHIg1$b-riwkOVcVz(_9krpbcYUp7koK6}+uAgyyw#@|3c{4%+M(%#uTJZjvW3^;4;xe#u zTNn8-9>3)MGynM}cdL>tnSR8RuaGQ#)8%vO6k4MO6~ozcV3DR$Yb}4RQWJLP+lIK` z-3WD4Hv})MV{yZP#CLP{7O4DOk6JlMdp}XWZLy3Ow!SZb&^TMHb^_f` z1q>w*^gG}-degvd?b2u%Zl=6F<{-jLu@x50-=a~s&qMaS)dn5L^zoW~Z@5z5wSHmL zQF|Gy>%uIw;E8&7kYv*=oJ#eS&Y9xZk7S5tzxOYC9 zI2&1rH0w<=6jgFn5?)X}RQrR8r67gJ`}n-6c1yn0?xU|9eLBj8%cm?kx07+D&fXry zaUFajlD%j;k{xe>{JGYh(R*H(5EE;m^!B4K1j!*ec?Ux$7PcQRg}f3!wBdf)s$OnF zlnO*b%AU$jv=9AW^hXvD>#K<)hSpSbcV2Ff!Hd2kQTT+G@G9n(VrvdhtTbJ>=h4-$y=U5k&h%w1N#D4$AXzlV+JyXDn2VcTWWTbqqtR4R>)3n3gZC`&NSpYu-09snrObOS4JmUhrL_O;`oP#bS~2 zg{`K(eWQE0XThu(T>Dg_nucu+B00qO%-VfeoJ8+4|=chROS_WR|u@fDBcXmPzitcO7br z&D_H}?z-V7H)i=liWk0_s(kFvEoDT1__l(++Z&o zpHjUtLXJ1gBIQh_QO#x91qy_wOeexj2b=l=#~o|jg@hbNflyNq^Shkfr& z7BN*u%yBNN;uGxL;}7YYL&<4!By4#x#Ow-V)M!W6?zKIZvBpnN=B;;G`pq!Bg00z( zu5D&@+yOicMr;VJ$d<=Sn~G>nT>P8XEt-~GyWLu+d`rfz1GGwqv!-)!!soEVsY-`y zXJ#b{-z+5a!nTQo#;Ei+TnD@>u#Y=`-`bPWV(hPYwjBniTKssjhR=ZIMcmy^Lua4@wyg&W!lju||Qh zlfq@JUI$v=DXZSAMzex@AL034Vk#|kv{Y`VQtrK-9PzeWIaW^M*i6s2qSeWeDYjKi zl?lo49;xuy1L5AsmO^c~Gr6SJpb62bpK4JwsjxQeyJXHZ9U@N1jRAi=CJPdSQE^Et z!t*vnMzI-zr;dE|tYSaDGtgbn6Gp?jwfevM_x%cbwn^txH5VtG?OWj^Cd@%yb*9A{ zIf|T#rt(Sw%O`NTy(`Enx_2Pn@ z&f^QnFpC^?;Z&kg39T#rmWKO+;@u3qoG0kAqqx(wEzPR}Rsg|_Qfz2A(tKHTjZtLU zqM1Q=s%HW2jqGOauhJ>Q-j=nFOE$_FSqOwzhjIVgW=Nx+qr*a>79bs*gd?grA2F-? z#FY_t^VMz$`ti!3azE`7+o<*rbgD`;yOYhG^*vhbD8u z33l(8!|Gi)PIm05O0eBik8XDG)_wH)eK{O&x&aiPd_qw$ES2^s8qwhTxxYoc1lokNrPZmVF14#}$2hcv^c$xOQH%MI7d)CU)q zXql!u`ra-w)cRi_;M74lkBkybIcZrcp!}>WFcYYt0H%*?ri^Y zSXgCcxXI?R7(iO*m3!zFL!MoZ8B}R0yv7WvPU16GohF@}l?s6Pfof2!e-jCuUOOd% zVgRJVx=5)N7|)+1%^7-qd)ajF1~eES9s%ekvb$4p0}L0DNdw9MF_uFbCkZ}uY+T08 zc_3Y4fUkE;_jH)5A}Z=ClvY=~aAXgKZs!6Wjo+Kp^NhP~Nd;P&2rZr_dZM$T9jrb9 zR*OBLG;H7*>{yLpi^7jU7y=~gBn1HP&O;CGCr-bCGe7`mp}$-JV}UcTvpH%Z)>Bhb zf~wpcB;)`(b(tjA@0Ol~2q9Xn$F) zSkvm@+U|ru7QP>;NMpU2sLL!~zRpM&A&=Z-Qa6X z=@5?N@po$}Cq{__jpjrsGEd%}@_b|Hx@Hf^xA?4NngS@{m${%B-uY^0Gpr`*w z$;~v~B7cC|U+dkbZPi%0!`})E^_ zRZ4KaM4Ihcq_SH%FrO*U&l19zON}>Z!nHhx*5grf7mu~uT8EhsuwwG~lA><^7s928 zb-cIQ^I5r4Z~Xpmdxd(>`s=M~xAIpvj><00+q1_P|4QkTJfKY&mn_S!@QS|HKuD;M z&mNwaWtlqNeoYF2Tq>=!Z9)!~HC1l-xUr`r0OKr3IND@DP*&9M1mKy|amF!YhhYpl zI_!n^X0^?Rmk9#+~nl6PwH7kMv0yRFA2f0IQv0Ktm= zK!yQH-Ep&%&0%lX_VwpaVYBQR);QnvX%9`hnL4n)!7ddL(<%s%Bc!_g)hzgRvHdX& zm<|L`->K!LsoKTbUzHk*KE(HOs>^`gwVCI$BW`p1_NLEA{h67B(>}#SKYSsQyDA{d zJI=#h6}ryGMOIitrwi)UOZ{2g)XPvaF)(2b2E4qvFj9fq%0^mb>+^pW`G+51Z?X%n z|2X~s$GHDLo`3fL$Mes^%FX=$v;6C9+HP?m`Mj#%6$?Gq{h^XU14jc^<^%>|69T?P zta3Baj&NJm7?&38J&IF)5xJjJyW){YCPEOA<#RIS;!HT8TlO=}@}*lf>z$ni%e?(R zrCoVERPXmkq@+l8;u1xS+3byd8`;XfGiENP#>_M`7^IM`@+q=Mwk!#KDpV+?vXqKa zO14l*yHq6cyE9to^Zk9kzkbXgbMKk6J2tGdqHe=sgpvr@n! zJN@3uI$ibhB5MVCj$8L~*NpDp{LExUmK(6FMX$Co=Wu0ajQY&z=X&sVOPN>Upnr`= z;+60(=*p-d=9Df*x=i+9R}<#G-5nM84@PUFxGJOXysn6M^AmTz{{GA7JrBF94&%xP z-Q*85-pUnXH;c5s|Ato+JUu;pKfv;eOu4+~d7qd4nWeKY%c`vlws7r^%vL}ath*>J zE}!*vr(K!ckWpS#Z)e!{!S41`;Hq;9sh_rcIuh;AxRu6w^P8UW4ZnJ}uWafnXTQ`v zzmvn*Lzjkj*xl;w8p4#v9*78+ka=sHaPZQQ=1fhst5wX%lg^w)OZbcjqLdt4aRyDQ9&Pu!^kT=k^2=u%`ah@68c?>TXSC=EiwLOp=~opF z244=pFdHYz+4#I90%sMIpMQ3%_Asw6x6B$r4o?igaq;4&sN#=8$*Zzgm|khY0}*O6 zQByC&+_Q|wIF9oSx4MdMOpHSg*zlPEEs^27rSjdBdAW)(2dkv?)$~&h#?PTlPwG9e zb}m$!ygM@Oh?;WhteBe78%}E;^OZ@zqr2Z8%iSrRNPBx6*Vu|RT%I&?=DsL!r=hx` zxmJsFs6O|u1T8ymc+5*&J24TE+^f5OkD)2I-sa9jFLV?kV6}#KSho{|46ZPYTnZ`K zg2|ltTQ_eV;wxEQF-LxUs>d@Srt$us$EL63-@(3ZO6tzwZfz-*&ZE3Dd<%-PqNsyJu*YZZ56O zH#~FvbZyg@pz4`h^*Ki$RFuAI=L!iM4EA`ZRadcQD*s=G#>oQ%%oU06oIZ)KOq4xdbW%NO?X9zXcxYa@`NNJh zjfoTviJQlo&E8bNW+#9dw1FFblLOUv0|HBv4H_?)?o#f~R;1=n5b30d10-u_6{3iN$d z^^QB~W0UvQZjTmeo@h;b@|J^+eKkSV{YkGci#)%RC^)xgZZ#rALdH8He$v)B=A1&5 z=9_TYz*J7Kg}i#l?D}tV;Nv{s>bl2=a&;c8F-Rr)dLUkI?mwZveSNdU5EUVxd2z%05~rvqHy zdQ99+h%+!1Wn}4Px&hfaAq8W~jbpyeRD0>*vpO1Yt_sC0^VtQZq-BY_Y9AeGHp|Zl zo}E$6ES7NM5vn2PcQIS?we~x1+4WRO*S{8)czr>%d;MQ+k=~9yt7MFlt}ti6Zs|d@g40pDCcX8``SzxfqpHESTk}1# zJ8O2&W;$1;*|r@=GbOYRX<1)!@N}<*K&ja8&X3*Iaek6*|<`)pN=8fk6S?MIYm zCo{2VCbeqc+n&8WulHj>Gke0r|aK+h+cUlCc?8nWxJzEds{B;{iGvm zeLhp>W@%nQP%KY3V*Sl^aeN9K*rX_q6!1V35`dssQ}PhJ)OYHe#6G7){%1PA`)#(g ztq|twrc{2D4BndNXX){N8@e_3@wjZqPQ@7Wve(g=RL2E3R|5PsGKX%L{&dMUD8b~-VF&*}m`mf{XwYx^u9Qyro8&)SUDpXqxGCwq`i38vveI)H+R z_E0w`8pvwo#J+LF`O)DHP;VzP10vErSbd!A0aj&a?*+F32}GPRh3SES?lm>hYG^O$ay_% z4l^>;9EwN|fV&XMMr1ni$NjGsQ(FI~7iLT$5tg7_`(KsY`H_gbg25%|*ZEidEa#VQ z1Ic15% zLM;JTs3qVEwFF$DmVhhN5^#lD0L$bWV$ET%#OU}v%4s0e_)4*W(1*o6axlwYL;dO->_ zff{`>h!nCZRD>v*>Y@-Rbp#5fg+d}U5ZcOcMZHJJv<0H|ERDxUwCq=G95%V-`=1j6!u>x;c z6Ok4};PK)}c_cpV*WAifEQy_sFBXaij8AfyNPBWxXdl*i5(W@`FaCXc`P7;Zq^OeQ z(R=hZ`k2nsu-#AP;8*fxK5*r7JuK(b|0vUF(}@myza zAm^Q9Qu!78bt;v-=FMKFX7%7eRk%PeWmTB!!O{`^8!Oro!(QJaw?(EqmF0wQ3CMef z>JWY26CGC0JCuc7OAV_(hOoPNUa#55=AqWtH{Up)iek3Nx!tOLb|EeI{#V3~-Fx5a zs7-nffE?a}l{UVPXBGMu$0={YBFB5#+~Dk0#$-EF1Uy{AH{dbkg@x zD%RdVF>U?zfm6hz+rgvGdOj!fWGnIlob-H@$}$4(tei-5PzhM`SGLJ|+cQj+-F@|= zn-o;cc9%ySpi?gqPyKz@RKV2w?(xq%ro5(H-@(hb?CFP>q946^P5V|a+qEz1OZ!7B{P#Y%WAh{= zzK{`*Q*FjQ>7ka7x~lgRGQ8JyAloMmYjm`WIN1BE8mcjDR=0<01>XZm1F7MCA6pu@E zwfoicyaEqNZ+$;z9O_fq>?NaL8kc=K)M~&4DbnSXl^Q38Um5-OLTcH`oHf@TJiI^* z-KC)b>Z($X9U%6#X;j3j+bHqgylL&FJaJP+*Y9YTD#fqE(5m4PJsDJJUwM5DJ?fqD znoG%wvxZi<;nlK319mS)2U>l;^-kqn@2SSk=6pS3-E#O%@NSnu{td$)pIh%R-c9O- zTF&fjC^l`-c?;aic*Fw3bL^zG0`WiO-4dp&{viy8PcqSgDAF;PDIKKktnP-qIP(zfK z?9xtDJXYpBdfZ$xfvTPpcvpL{uqP|FC7bj1+rf`l%+gvKgl?Z&uO9M5U9(a>WH*EI z(6kO+u)FczhS9apHe1(a+}&`)F789c9A_oZ_Vj-q*dYH=4ci4XNJKK!czb>=L4Gd) ztZ~r7^5bT{CpT)rqP3_n{*TKmv`~KUDn03umWULGJ4^yFPMY!Eh76EKj74X1$);kJ(W@SS)ag1mt~ABL&R459`> zCIDs-iA>jJ>dE7vF6N*vbj>2ehdb9O{0KLct&o7(I;4z%gND`UbX+?^>YA!qbRUR(S-l zwBr0I!3>uF^G*@)KQ&W>X(YB@0v-;Mz#!I19UQ5KWcfKifFSBmW-?ukJs34SB?!*M zQ6WaeLPi^#e`gH}f`9iC@{@qq^=F+MG4yd%DhWEjV|fdQ>N5=d*GJH_u>M%g3>M<_ z*^^{MV$5HVh%7I4NjP$Vo;(vq0R3^nB!;~H?`0nb&YA@hQGfn|EzhztuXBMBJgcar z(B`!p;{yKO%;IYBHzRCmmXRMi7nmXZdOrWh5&go-Z)SduWueX=rXVcpvP)NxRgvnm zYGfD!1w&~$qL8`>4PAt`lcNa+{u|puX{eYb`iK2j=>=?yq`$}7f0tj#xIlxAjjkyL zAIyTKrKx^!Fp;3Eqp9hS@JDFE)X@YSOkD?og!!Sg5HO@B0@T2R2$Z%To;{?+vWt~k zlIaW_84vz{lrE5EE1iEJ3qp<^%xuI#(G2AT_|K#O-Tcfc&^I0Q`#HV(5y?405E95(C;9?e`I(_iggrX54VP5LurSds*qd3JdSK9+n(AoCXEX|WAv87g|4H$Jvt5`UF683;uREX-I&cSKIF!fH znsC Date: Wed, 28 Feb 2024 20:59:44 +0100 Subject: [PATCH 035/377] #15 - Skeleton for tests --- backend/pylintrc | 1 + backend/tests/endpoints/conftest.py | 27 +++++++- backend/tests/endpoints/submissions_test.py | 75 +++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 backend/tests/endpoints/submissions_test.py diff --git a/backend/pylintrc b/backend/pylintrc index 83eff274..86b66862 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -4,6 +4,7 @@ init-hook='import sys; sys.path.append(".")' [test-files:*_test.py] disable= W0621, # Redefining name %r from outer scope (line %s) + R0904, # Too many public methods (too many unit tests essentially) [modules:project/modules/*] disable= diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 010ef293..88bdb75a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,6 +1,29 @@ """ Configuration for pytest, Flask, and the test client.""" import pytest -from project import create_app +from project import create_app_with_db +from project.database import db, get_database_uri + +@pytest.fixture +def session(): + """Create a database session for the tests""" + # Create all tables + db.create_all() + + # Populate the database + db.session.commit() + + # Tests can now use a populated database + yield db.session + + # Rollback + db.session.rollback() + + # Remove all tables + for table in reversed(db.metadata.sorted_tables): + db.session.execute(table.delete()) + db.session.commit() + + db.session.close() @pytest.fixture def app(): @@ -8,7 +31,7 @@ def app(): Returns: Flask -- A Flask application instance """ - app = create_app() + app = create_app_with_db(get_database_uri()) yield app @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py new file mode 100644 index 00000000..e5f3e856 --- /dev/null +++ b/backend/tests/endpoints/submissions_test.py @@ -0,0 +1,75 @@ +"""Test the submissions API endpoint""" + +class TestSubmissionsEndpoint: + """Class to test the submissions API endpoint""" + + ### GET SUBMISSIONS ### + def test_get_submissions_wrong_user(self, client, session): + """Test getting submissions for a non-existing user""" + + def test_get_submissions_wrong_project(self, client, session): + """Test getting submissions for a non-existing project""" + + def test_get_submissions_database_issue(self, client, session): + """Test getting the submissions with a faulty database""" + + def test_get_submissions_correct(self, client, session): + """Test getting the submissions""" + + ### POST SUBMISSIONS ### + def test_post_submissions_wrong_user(self, client, session): + """Test posting a submission for a non-existing user""" + + def test_post_submissions_wrong_project(self, client, session): + """Test posting a submission for a non-existing project""" + + def test_post_submissions_wrong_grading(self, client, session): + """Test posting a submission with a wrong grading""" + + def test_post_submissions_wrong_form(self, client, session): + """Test posting a submission with a wrong data form""" + + def test_post_submissions_wrong_files(self, client, session): + """Test posting a submission with no or wrong files""" + + def test_post_submissions_database_issue(self, client, session): + """Test posting the submissions with a faulty database""" + + def test_post_submissions_correct(self, client, session): + """Test posting a submission""" + + ### GET SUBMISSION ### + def test_get_submission_wrong_id(self, client, session): + """Test getting a submission for a non-existing submission id""" + + def test_get_submission_database_issue(self, client, session): + """Test getting a submission with a faulty database""" + + def test_get_submission_correct(self, client, session): + """Test getting a submission""" + + ### PATCH SUBMISSION ### + def test_patch_submission_wrong_id(self, client, session): + """Test patching a submission for a non-existing submission id""" + + def test_patch_submission_wrong_grading(self, client, session): + """Test patching a submission with a wrong grading""" + + def test_patch_submission_wrong_form(self, client, session): + """Test patching a submisson with a wrong data form""" + + def test_patch_submission_database_issue(self, client, session): + """Test patching a submission with a faulty database""" + + def test_patch_submission_correct(self, client, session): + """Test patching a submission""" + + ### DELETE SUBMISSION ### + def test_delete_submission_wrong_id(self, client, session): + """Test deleting a submission for a non-existing submission id""" + + def test_delete_submission_database_issue(self, client, session): + """Test deleting a submission with a faulty database""" + + def test_delete_submission_correct(self, client, session): + """Test deleting a submission""" From cfe6dd4ad59ea21f103edc2410e78be673043f0a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 28 Feb 2024 21:03:04 +0100 Subject: [PATCH 036/377] #15 - Changing Exception to exc.SQLAlchemyError --- backend/project/endpoints/submissions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index f6c4e4d8..eb0c7bd1 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -3,6 +3,7 @@ from datetime import datetime from flask import Blueprint, request from flask_restful import Resource +from sqlalchemy import exc from project.database import db from project.models.submissions import Submissions as m_submissions from project.models.projects import Projects as m_projects @@ -54,7 +55,7 @@ def get(self, uid: str, pid: int) -> dict[str, any]: submissions = session.query(m_submissions).filter_by(uid=uid, project_id=pid).all() submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] return {"submissions": submissions_urls} - except Exception: + except exc.SQLAlchemyError: return {"message": f"An error occurred while fetching the submissions " f"from user {uid} for project {pid}"}, 500 @@ -121,7 +122,7 @@ def post(self, uid: str, pid: int) -> dict[str, any]: session.add(submission) session.commit() return {"submission": f"/submissions/{submission.submission_id}"}, 201 - except Exception: + except exc.SQLAlchemyError: session.rollback() return {"message": f"An error occurred while creating a new submission " f"for user {uid} in project {pid}"}, 500 @@ -169,7 +170,7 @@ def get(self, sid: int) -> dict[str, any]: "submission_path": submission.submission_path, "submission_status": submission.submission_status } - except Exception: + except exc.SQLAlchemyError: return {"message": f"An error occurred while fetching submission {sid}"}, 500 def patch(self, sid:int) -> dict[str, any]: @@ -215,7 +216,7 @@ def patch(self, sid:int) -> dict[str, any]: # Save the submission session.commit() return {"message": f"Submission {sid} updated"} - except Exception: + except exc.SQLAlchemyError: session.rollback() return {"message": f"An error occurred while patching submission {sid}"}, 500 @@ -254,7 +255,7 @@ def delete(self, sid: int) -> dict[str, any]: session.delete(submission) session.commit() return {"message": f"Submission {sid} deleted"} - except Exception: + except exc.SQLAlchemyError: db.session.rollback() return {"message": f"An error occurred while deleting submission {sid}"}, 500 From 450c6614f2d8b5315392ed3f690417bdcf02becf Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 28 Feb 2024 21:07:59 +0100 Subject: [PATCH 037/377] #15 - Removing some commented out code --- backend/project/endpoints/submissions.py | 72 +----------------------- 1 file changed, 1 insertion(+), 71 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index eb0c7bd1..a8e9bb61 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -25,22 +25,8 @@ def get(self, uid: str, pid: int) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Check user user = session.get(m_users, uid) if user is None: @@ -70,22 +56,8 @@ def post(self, uid: str, pid: int) -> dict[str, any]: dict[str, any]: The URL to the submission """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if uid_operator != uid: - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - submission = m_submissions() # User @@ -113,7 +85,7 @@ def post(self, uid: str, pid: int) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - # get the files and store them + # Get the files, store them, test them ... submission.submission_path = "/tbd" # Submission status @@ -140,22 +112,8 @@ def get(self, sid: int) -> dict[str, any]: dict[str, any]: The submission """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not (operator.is_admin or operator.is_teacher or uid_operator == uid): - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Get the submission submission = session.get(m_submissions, sid) if submission is None: @@ -183,22 +141,8 @@ def patch(self, sid:int) -> dict[str, any]: dict[str, any]: A message """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not operator.is_teacher: - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Get the submission submission = session.get(m_submissions, sid) if submission is None: @@ -230,22 +174,8 @@ def delete(self, sid: int) -> dict[str, any]: dict[str, any]: A message """ - # Authentication - # uid_operator = 0 - # if uid_operator is None: - # return {"message": "Not logged in"}, 401 - try: with db.session() as session: - # Authorization - # operator = session.get(m_users, uid_operator) - # if operator is None: - # return {"message": f"User {uid_operator} not found"}, 404 - # if not operator.is_admin: - # return { - # "message": f"User {uid_operator} does not have the correct rights" - # }, 403 - # Check if the submission exists submission = session.get(m_submissions, sid) if submission is None: From 46b0db521046dca2d0a80aee8211f6f190985d07 Mon Sep 17 00:00:00 2001 From: warre Date: Thu, 29 Feb 2024 12:08:12 +0100 Subject: [PATCH 038/377] requested style change added --- backend/project/endpoints/users.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 1c48008b..4a2a09b3 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -1,8 +1,10 @@ """Users api endpoint""" from flask import Blueprint, request, jsonify from flask_restful import Resource, Api +from sqlalchemy.exc import SQLAlchemyError + from project import db # pylint: disable=import-error ; there is no error -from project.models.users import Users as UserModel # pylint: disable=import-error ; there is no error +from project.models.users import Users as userModel # pylint: disable=import-error ; there is no error users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -16,7 +18,7 @@ def get(self): This function will respond to get requests made to /users. It should return all users from the database. """ - users = UserModel.query.all() + users = userModel.query.all() return jsonify(users) @@ -39,16 +41,16 @@ def post(self): } }, 400 try: - user = db.session.get(UserModel, uid) + user = db.session.get(userModel, uid) if user is not None: # bad request, error code could be 409 but is rarely used return {"Message": f"User {uid} already exists"}, 400 # Code to create a new user in the database using the uid, is_teacher, and is_admin - new_user = UserModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) + new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) db.session.add(new_user) db.session.commit() - except Exception as e: # pylint: disable=broad-exception-caught ; + except SQLAlchemyError as e: # every exception should result in a rollback db.session.rollback() return {"Message": f"An error occurred while creating the user: {str(e)}"}, 500 @@ -64,7 +66,7 @@ def get(self, user_id): This function will respond to GET requests made to /users/. It should return the user with the given user_id from the database. """ - user = db.session.get(UserModel, user_id) + user = db.session.get(userModel, user_id) if user is None: return {"Message": "User not found!"}, 404 @@ -81,7 +83,7 @@ def patch(self, user_id): is_teacher = request.json.get('is_teacher') is_admin = request.json.get('is_admin') try: - user = db.session.get(UserModel, user_id) + user = db.session.get(userModel, user_id) if user is None: return {"Message": "User not found!"}, 404 @@ -92,7 +94,7 @@ def patch(self, user_id): # Save the changes to the database db.session.commit() - except Exception as e: # pylint: disable=broad-exception-caught ; + except SQLAlchemyError as e: # every exception should result in a rollback db.session.rollback() return {"Message": f"An error occurred while patching the user: {str(e)}"}, 500 @@ -104,13 +106,13 @@ def delete(self, user_id): It should delete the user with the given user_id from the database. """ try: - user = db.session.get(UserModel, user_id) + user = db.session.get(userModel, user_id) if user is None: return {"Message": "User not found!"}, 404 db.session.delete(user) db.session.commit() - except Exception as e: # pylint: disable=broad-exception-caught ; + except SQLAlchemyError as e: # every exception should result in a rollback db.session.rollback() return {"Message": f"An error occurred while deleting the user: {str(e)}"}, 500 From d2f3a2b0d412dfcf3a08d9b8dc88438039e59822 Mon Sep 17 00:00:00 2001 From: warre Date: Thu, 29 Feb 2024 12:33:35 +0100 Subject: [PATCH 039/377] pylnt complaints --- backend/tests/endpoints/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index c72a20c4..be659d9a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -26,7 +26,7 @@ def user_db_session(): yield session session.rollback() session.close() - for table in reversed(db.metadata.sorted_tables): + for table in reversed(db.metadata.sorted_tables): # pylint: disable=duplicate-code session.execute(table.delete()) session.commit() From 2c7322edc7785d02442b152f53be2799ad2c0eb2 Mon Sep 17 00:00:00 2001 From: warre Date: Thu, 29 Feb 2024 16:17:44 +0100 Subject: [PATCH 040/377] moved fixture --- backend/tests/endpoints/conftest.py | 25 ------------------------- backend/tests/endpoints/user_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index be659d9a..0afc0c66 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,36 +1,11 @@ """ Configuration for pytest, Flask, and the test client.""" import pytest from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project import db -from project.models.users import Users from tests import db_url -engine = create_engine(db_url) -Session = sessionmaker(bind=engine) -@pytest.fixture -def user_db_session(): - """Create a new database session for the user tests. - After the test, all changes are rolled back and the session is closed.""" - db.metadata.create_all(engine) - session = Session() - session.add_all( - [Users(uid="del", is_admin=False, is_teacher=True), - Users(uid="pat", is_admin=False, is_teacher=True), - Users(uid="u_get", is_admin=False, is_teacher=True) - ] - ) - session.commit() - yield session - session.rollback() - session.close() - for table in reversed(db.metadata.sorted_tables): # pylint: disable=duplicate-code - session.execute(table.delete()) - session.commit() - - @pytest.fixture def app(): """A fixture that creates and configure a new app instance for each test. diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 6bbd26db..c5dee2d9 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -7,6 +7,34 @@ - test_patch_user: Tests user update functionality and error handling for updating non-existent user. """ +import pytest +from sqlalchemy.orm import sessionmaker +from sqlalchemy import create_engine +from project.models.users import Users +from project import db +from tests import db_url + +engine = create_engine(db_url) +Session = sessionmaker(bind=engine) +@pytest.fixture +def user_db_session(): + """Create a new database session for the user tests. + After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) + session = Session() + session.add_all( + [Users(uid="del", is_admin=False, is_teacher=True), + Users(uid="pat", is_admin=False, is_teacher=True), + Users(uid="u_get", is_admin=False, is_teacher=True) + ] + ) + session.commit() + yield session + session.rollback() + session.close() + for table in reversed(db.metadata.sorted_tables): # pylint: disable=duplicate-code + session.execute(table.delete()) + session.commit() class TestUserEndpoint: """Class to test user management endpoints.""" From 5ff71c08500e433e1df20b0629e0dd6d13fa5a71 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 1 Mar 2024 10:38:38 +0100 Subject: [PATCH 041/377] #15 - Updating the endpoint to use query parameters --- .../endpoints/index/OpenAPI_Object.json | 65 ++++++++---------- backend/project/endpoints/submissions.py | 67 +++++++++---------- 2 files changed, 57 insertions(+), 75 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 7b855bbd..23d9df3d 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -44,7 +44,25 @@ "paths": { "/submissions/{uid}/{pid}": { "get": { - "summary": "Get all submissions from a user for a project", + "summary": "Get the submissions", + "parameters": [ + { + "name": "uid", + "in": "query", + "description": "User ID", + "schema": { + "type": "string" + } + }, + { + "name": "project_id", + "in": "query", + "description": "Project ID", + "schema": { + "type": "int" + } + } + ], "responses": { "200": { "description": "A list of submission URLs", @@ -64,21 +82,6 @@ } } }, - "404": { - "description": "A 'not found' message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - }, "500": { "description": "An error message", "content": { @@ -99,12 +102,18 @@ "post": { "summary": "Post a new submission to a project", "requestBody": { - "description": "Grading", + "description": "Form data", "content": { "application/json": { "schema": { "type": "object", "properties": { + "uid": { + "type": "string" + }, + "project_id": { + "type": "int" + }, "grading": { "type": "int", "minimum": 0, @@ -177,27 +186,7 @@ } } } - }, - "parameters": [ - { - "name": "uid", - "in": "path", - "description": "User ID", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "pid", - "in": "path", - "description": "Project ID", - "required": true, - "schema": { - "type": "int" - } - } - ] + } }, "/submissions/{sid}": { "get": { diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index a8e9bb61..cf4b4682 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -1,6 +1,7 @@ """Submission API endpoint""" from datetime import datetime +from os import getenv from flask import Blueprint, request from flask_restful import Resource from sqlalchemy import exc @@ -14,44 +15,39 @@ class Submissions(Resource): """API endpoint for the submissions""" - def get(self, uid: str, pid: int) -> dict[str, any]: + def get(self) -> dict[str, any]: """Get all the submissions from a user for a project - Args: - uid (str): User ID - pid (int): Project ID - Returns: dict[str, any]: The list of submission URLs """ try: with db.session() as session: - # Check user - user = session.get(m_users, uid) - if user is None: - return {"message": f"User {uid} not found"}, 404 + query = session.query(m_submissions) + + # Filter by uid + uid = request.args.get("uid") + if (uid is not None) and (session.get(m_users, uid) is not None): + query.filter_by(uid=uid) - # Check project - project = session.get(m_projects, pid) - if project is None: - return {"message": f"Project {pid} not found"}, 404 + # Filter by project_id + project_id = request.args.get("project_id") + if (project_id is not None) and (session.get(m_projects, project_id) is not None): + query.filter_by(project_id=project_id) # Get the submissions - submissions = session.query(m_submissions).filter_by(uid=uid, project_id=pid).all() - submissions_urls = [f"/submissions/{s.submission_id}" for s in submissions] + submissions = query.all() + submissions_urls = [ + f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in submissions + ] return {"submissions": submissions_urls} except exc.SQLAlchemyError: - return {"message": f"An error occurred while fetching the submissions " - f"from user {uid} for project {pid}"}, 500 + return {"message": "An error occurred while fetching the submissions"}, 500 - def post(self, uid: str, pid: int) -> dict[str, any]: + def post(self) -> dict[str, any]: """Post a new submission to a project - Args: - uid (str): User ID - pid (int): Project ID - Returns: dict[str, any]: The URL to the submission """ @@ -61,16 +57,16 @@ def post(self, uid: str, pid: int) -> dict[str, any]: submission = m_submissions() # User - user = session.get(m_users, uid) - if user is None: + uid = request.form.get("uid") + if (uid is None) or (session.get(m_users, uid) is None): return {"message": f"User {uid} not found"}, 404 submission.uid = uid # Project - project = session.get(m_projects, pid) - if project is None: - return {"message": f"Project {pid} not found"}, 404 - submission.project_id = pid + project_id = request.form.get("project_id") + if (project_id is None) or (session.get(m_projects, project_id)): + return {"message": f"Project {project_id} not found"}, 404 + submission.project_id = project_id # Grading if "grading" in request.form: @@ -93,11 +89,12 @@ def post(self, uid: str, pid: int) -> dict[str, any]: session.add(submission) session.commit() - return {"submission": f"/submissions/{submission.submission_id}"}, 201 + return { + "submission": f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" + }, 201 except exc.SQLAlchemyError: session.rollback() - return {"message": f"An error occurred while creating a new submission " - f"for user {uid} in project {pid}"}, 500 + return {"message": "An error occurred while creating a new submission "}, 500 class Submission(Resource): """API endpoint for the submission""" @@ -189,9 +186,5 @@ def delete(self, sid: int) -> dict[str, any]: db.session.rollback() return {"message": f"An error occurred while deleting submission {sid}"}, 500 -submissions_bp.add_url_rule( - "/submissions//", - view_func=Submissions.as_view("submissions")) -submissions_bp.add_url_rule( - "/submissions/", - view_func=Submission.as_view("submission")) +submissions_bp.add_url_rule("/submissions", view_func=Submissions.as_view("submissions")) +submissions_bp.add_url_rule("/submissions/", view_func=Submission.as_view("submission")) From 010fc890411687e7cc7133afe6cdd0de558de1af Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 1 Mar 2024 16:09:10 +0100 Subject: [PATCH 042/377] #15 - Tests for get submissions --- backend/project/endpoints/submissions.py | 29 +++++++---- backend/tests/endpoints/conftest.py | 23 +++++---- backend/tests/endpoints/submissions_test.py | 53 ++++++++++++++++++--- 3 files changed, 82 insertions(+), 23 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index cf4b4682..188e7def 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -22,28 +22,39 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ + data = {} try: with db.session() as session: query = session.query(m_submissions) # Filter by uid uid = request.args.get("uid") - if (uid is not None) and (session.get(m_users, uid) is not None): - query.filter_by(uid=uid) + if uid is not None: + if session.get(m_users, uid) is not None: + query = query.filter_by(uid=uid) + else: + data["message"] = f"Invalid user (uid={uid})" + return data, 400 # Filter by project_id project_id = request.args.get("project_id") - if (project_id is not None) and (session.get(m_projects, project_id) is not None): - query.filter_by(project_id=project_id) + if project_id is not None: + if session.get(m_projects, project_id) is not None: + query = query.filter_by(project_id=project_id) + else: + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 # Get the submissions - submissions = query.all() - submissions_urls = [ - f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in submissions + data["message"] = "Successfully fetched the submissions" + data["submissions"] = [ + f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in query.all() ] - return {"submissions": submissions_urls} + return data, 200 + except exc.SQLAlchemyError: - return {"message": "An error occurred while fetching the submissions"}, 500 + data["message"] = "An error occurred while fetching the submissions" + return data, 500 def post(self) -> dict[str, any]: """Post a new submission to a project diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 88bdb75a..d61dd089 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,29 +1,34 @@ """ Configuration for pytest, Flask, and the test client.""" import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project.database import db, get_database_uri +engine = create_engine(get_database_uri()) +Session = sessionmaker(bind=engine) @pytest.fixture def session(): """Create a database session for the tests""" # Create all tables - db.create_all() + db.metadata.create_all(engine) + + session = Session() # Populate the database - db.session.commit() + session.commit() # Tests can now use a populated database - yield db.session + yield session # Rollback - db.session.rollback() + session.rollback() + session.close() # Remove all tables for table in reversed(db.metadata.sorted_tables): - db.session.execute(table.delete()) - db.session.commit() - - db.session.close() + session.execute(table.delete()) + session.commit() @pytest.fixture def app(): @@ -31,7 +36,9 @@ def app(): Returns: Flask -- A Flask application instance """ + engine = create_engine(get_database_uri()) app = create_app_with_db(get_database_uri()) + db.metadata.create_all(engine) yield app @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index e5f3e856..5971a60e 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -1,20 +1,61 @@ """Test the submissions API endpoint""" +from os import getenv + class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" ### GET SUBMISSIONS ### - def test_get_submissions_wrong_user(self, client, session): + def test_get_submissions_wrong_user(self, client): """Test getting submissions for a non-existing user""" + response = client.get("/submissions?uid=unknown") + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid user (uid=unknown)" - def test_get_submissions_wrong_project(self, client, session): + def test_get_submissions_wrong_project(self, client): """Test getting submissions for a non-existing project""" + response = client.get("/submissions?project_id=-1") + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=-1)" - def test_get_submissions_database_issue(self, client, session): - """Test getting the submissions with a faulty database""" - - def test_get_submissions_correct(self, client, session): + def test_get_submissions_all(self, client): """Test getting the submissions""" + response = client.get("/submissions") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1", + f"{getenv('HOSTNAME')}/submissions/2" + ] + + def test_get_submissions_user(self, client): + """Test getting the submissions given a specific user""" + response = client.get("/submissions?uid=user4") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1" + ] + + def test_get_submissions_project(self, client): + """Test getting the submissions given a specific project""" + response = client.get("/submissions?project_id=1") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1" + ] + + def test_get_submissions_user_project(self, client): + """Test getting the submissions given a specific user and project""" + response = client.get("/submissions?uid=user4&project_id=1") + data = response.json + assert response.status_code == 200 + assert data["submissions"] == [ + f"{getenv('HOSTNAME')}/submissions/1" + ] ### POST SUBMISSIONS ### def test_post_submissions_wrong_user(self, client, session): From 80e5a242367724d795ac22d8cdb5bfa295ca2f02 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 1 Mar 2024 16:16:54 +0100 Subject: [PATCH 043/377] #15 - Forget to update the OpenAPI document --- .../endpoints/index/OpenAPI_Object.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 23d9df3d..78d4fc80 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -71,6 +71,9 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "submissions": { "type": "array", "items": { @@ -82,6 +85,21 @@ } } }, + "400": { + "description": "An invalid data message", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + }, "500": { "description": "An error message", "content": { From 723207fd87124bfd3128e7e700b7ded3b0be30d3 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Fri, 1 Mar 2024 18:16:51 +0100 Subject: [PATCH 044/377] rm student info (#35) --- usecases/usecase_student.pdf | Bin 19115 -> 17142 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/usecases/usecase_student.pdf b/usecases/usecase_student.pdf index 9640452ac315478189df6ff0c048cc7e83dc931a..6be165f2d40918f3b021425c170aedca92d474f1 100644 GIT binary patch delta 13407 zcma*NV~{36w=LYZZQFR-wr$(fHlOyiZQIkfJ#CxQ)3&WU?>Xm-dvDx7Uqxia%B)?P zwKJ;Lu3S5-Q$e4?KohhfczD>j6AhR_X%k}!fMm37pRMv;n^%UGjqEk8;)x@s{!P|T zLrkCdrX_nH^&ihp9~Rf$9|qrDALBPFpVxQg5Bd8uZ=@WC^DILN8y&a%=|+bdpS!@% z_p9B2SNQLW&+(?o&6k(D$?LxNec;FJ)%Ew6C-7@X=+l|&Z`}A2@k8Y|YN59-DyHXVLczdtUezqYxeb&i)x3`MfHdB=(B6`_< zcz4{0^UWuYFaff8KjtuQcEhm4oB`RV;SBm|%I z>z^j^={H`EgG)2o#V4(w_^(P4->PWcS>PI)IPcw+lNkXR^(D^nR%Y zJYBFzl3ceZk485*ndEhtejJT#-=uSi+d_A{ z93D&s%1BT@m{Gycm0wRAf)K-z2oYde%g$xwaSF!r_7Icf|IEc-19&OF={*Jld&>cM zTxqN|P;P6V!sX1OER!z}DyNF0X9_mQy{yr!h*R9u!YE1j@}Nk3LfB)|hlI`R_#mff zvwFp@BlSp|TH_qGsxTRx4J@$SrMEid$pmPI2eSjlOExQBWU=b?=8Iw;qf0$sd+yZt z&;bXtcg%ldaUe++{WYYq+=C>6HIX%W40dlT{Eg22Ao5foT>1r&b@&3)fI@t6%fAuj zFF0f72UT$PydEsB^zd{MyBj9P@aVHbE9wPI{z6sUG?!RF{t;MxY`i zQCAVy=&IRF3A#S>>4oaWoxw|GlB)Qu%Ins!u&saAM}lQ%22AYx|3btAB{TD5P*To1 zfp4lS|K|WW5IE zg!u}5?--r*3O{WEdWsJ>11XKwn31$H z{k8E8e1f0_^mRrwN3Xjey656urU2osG(gpS^1q~!2Gg)f9v1LC#2OnPjXpK5S5^Yvk43P&Ud)4}fUhz6=v6Quw&5?SFh>%&Ps%FY6npI7qGI%3LJ8kre zy@|IlZxGoO48qczA1j0=F^9+_cm`TaL8`(LoJ=L7IAVHhLV+shk6?DN+BM`})Ya5j zw*!>M@z4g~JX+Powdt-3CO8-!i#bEeik@E#rr;X$QXEe)c@Udqff)C;Hy0?8GMC8P zl(uu4Pu|ZT9U}KUbYffPKOzV8BBRr;B^Pz~nzv9Jjm5=UokyGPt{mNB|2PKo=f zYkF;~ANmUpT>55QIS{Z|_HA|;?^Y``X^boN;%&ZCe@zVq7Wz+e-fVc>?MO;p&pZ^j zaj;H-HE-xTEq!W%4NaZpChmo)%ZW=Yn8Rj)I$p!U!?JoWarM%Ne4_5d%yR=L&HR`+ zftZRFphV%$@<>Qnf}lX%XS}@SeT)bm?JJB_t<-i9Gq^3 zYm)`%vqY?Er(m`*Uavl7PZ=sOfzZA`%c;+=xC&r@dm{_?aqR14@*coqMBrgknR$nzvFWdYAeAlN6ZwEvAou`Web?@a@#9H-JbXQkZji8#dT=Zj za8kqKxa5?L&noB0=JA?Nff{+iG+}2Lp%=?Z*{N1|T*YV6JGI!&?wB_{rKa72$-%@v zs9hQco5&PfHXkAy1Y3pM0q>)w#~|`Ge__=vuXhxl*4`uVBd-v&N5+Ql)c~zJgAeNR zo68nn`H?i>cK2a5htINlict7y(A zatwv&zSnJ%i0tf$sJ=7~A&1GqB?Q1;RhA10vejTA>4V1$3|qm0cUoiJS0HJe>#$X` z1EG*TKtK>7NqG_pOBYtAAg#?H#U^1W2=9rQ@2-GI&^}?bZG{PsFzn%^%s!R??vxQt zHn55yS|J+~=`{p3L@|3Dq*tCtUIhy=iDJaGEzlPH25WlOTzSLI*`D;D`P1DbSZnX^ z^OZ$rjGk6LYH;&d40laJ71{yIx;Xk7+Yn@F!nmo2FWc ze)VrSX}o>yIg?JFO5@Au$j8&$`BGZs?N}^N8{z6_BWr0b==bGKB_|?I2PxY}BjM>Z z3S)wvvh@%1eTyCwEtCa025F)pX8hLLHWk}lC-U2BJm?(F?L3M8G7<%T;I+7CHA!qN zUI}U&Qg`6x9>(QNV;2tli*CQjKnK`vy!6)EYs(%+*UhIO1y5MP!{xFRepi*G>^VyJ#?MtQ;$ z_km10Ute23-y7z>tYU&1h*&Mq+9-LM_A%8S&!oY9`@{-mVgPcHVTLex%Mf>|m*@sT zLWKl3I`K+pMSWr(2q|b&K(UI9KFYTg3!gd`VExD{M{DsDtVk2|a|O=H7=lr}Hqt_e zrv5a=@)`7_{A-P>VcBc$`@MSgS(f}XAoBC)3#H%Yn_oENE!ku^5T}kI`e({=stJXL zWt68?e9SM;=~!bZF}X3n-e8oo>N~#O-c^%)R3uEVfh>gm0!iV|Epw z+V|3bbZBB)xNEe9fD@&UO==3o{066g%6^l@%)tX7fy0VRPK+%8HDwKQ=WH<`3*w6T zEc-7(PqDmH-)J*kta9is4^KD`M#;y=lRnZl4%vD;QhIdX`g1+{RDrc+_vTM<_hzDt zvw>Qo3*9>7W3}1elLzkc z5RJGSLex8_s~Z_3eF33(c!3|G~uw`w8lz+_c9I|Hd{h7}AGY@ffvL%36VyK1jC^9%(uB*FR=sa10uGXvtpnPpyX0-__lO*x-RNWB_?04Pa#*=h{1-kL6}-gAZ`&;t zK%?OAZnj`qor5K`4pGGnWgUog+8pI^NSbtFuoX2NU=dSRVTIWr@Oh*+I;VGihRd1_ zcwli#a_Qp%v%77Ci6t#=ptO~sr(%p{Tj>Xuv^WP#5(?-H%P9=2O>D6E1EjkjoCdN4 z0wD(511p*+G#iIEAsQTpAr|Gh?}WgG1IXYY)m7Azm<^G-_>Cca$-zh?j5x?z<4Z2B zMfA-f&^ADyMFCggP&r4UvaOC2$gBrdp5$!%&P^vQW@fh+|h;e?E9!d z*O?#jatCX~rUCEl-oiRobCEOEzzU@Wo8(EX6;CO9R})4BS!tu8ug+rBmxeBwaD zgtE`u3whYCU^DB^I1uVQadxZMF(|e5@f1?CmZtu%nGnXa{|AbF^!g`C{EgUf>n3q0 zanakBz$eNkS<9YZiHD$(h$is7bsw{mvO|bu>9lI>)ZTnr@3S)| z#WhRK3S3fo#kR2~qp3+AI(puR*%V!hZs=Q|HItcz&Tod_HNeJx36&W>&l#nnaSVFd zS=h6{og&CvcuGOBHO5ATv(473ClO>uwGL7-Of3238P=IG8nXA+vsg3p(yYT)xWr%B zFz)1!lQ@;JTFwGAUY_Muq1#SVmtA;qh0gw+)a|~(j<*oMIXkmgU7|**SAw6yrNInJ zBcamtoOuqEw16c1uDU-V7~MebtJaXR6sH5EGA?{M3emW+Z@DZr4iLLYIh-?2+09au z*z;lPv{|EzCx$SA6&Ev@K$Q6CNp;NV_df+9Tx+=*8Ff_S!5b5mBuBUc7+|$hEv)8G z<61_Hew5({hB(A-(Ldr_udXocFRl0l_kKv(1r4IVn*gh{aksMoma`kk1@}|*N|(@y zI>A##s@0zbRTTx^#hV-IZx{G{WcwFl7cfSe${c+^*HL4Sy_|+zpMt7nsmKe{*hLP4 z{4}QescG)B>p>>)Il^)a{0!t`A#a+`lHs+A=_il3sTJyWyQq4pxNnv*0KHK6`nT{Q z!9-f9&p`7@KO{Vo7Ab|lvC{OM9G@}Z8dxUvYt(}vKgNbUEj~CpVQ4!sFx+(53=1ol z;rtKAGY!A$C*R`7Yiw1}`QEO>z8Xr+-#jg#qOY*d^){qpH?#nLfg$Zax(gMpgxwiAS~0iP%$+pG=x!G^nXj#KaOU zsR^UJZXuSeraSeTuCzo52gj{fhk9_3cN#M+9ihjWyqA^{$URmQt=`OiVRuf_M*=h= zY=F4U-=rSZ(grmHj13*AyB990v|+7~gaWy?UW0CQyR8{UontRDB#aqY2wVM%)VKCR zNx4roL3PO-b84GH+$}wmh2;rivC*(Ucgbt>Y-;cx4VDMz(#|(O8S@VuZKg)9oY$)V z)>|g}B4!u(_Fo@@I(D#~Vy%vK$GdhixB`V9`BUo@cmHXQ#o<_Rj5vL9KFc2|-Gwj9%giT{EzcJ{E#Ojj6I zgo&hvZ3LSR?H-ooFQnN)r%%}C3%a2|*c6eqpr(PrfN(jLvG@x=trIjCjROat0^Gq* ze`IqkaDFTiWs0>RIKJXMx6!HMT*_wC9+4Cku#`=x#Lidpz79&j5Mx2?P;*bpT=Z z1TRT#2u74k%hU{?3*Pg*hPqKL0rt6pm&^o9=12M-fW?^4!!jC1J?u|wecJ2%RY>;& zZdo~Y;~R(8G}<&7x^U!o;r3tt3S-}tXh>c_?szSn`H^;#z%lC}NWXw>C{M5APPAsJ z;qN0R-q!rkHh2gsg8zrP#YI`anJ_WwFMZ3{XUQ9FJRd^{%#ZZ2&rm5R2p}Ix?etoo zw6%aTll|#WY&0ArOufH>M<0g_Bbkd72%&1f{25n-BaSCQ%$a;}K`a$p3b-v?Ff+T1 zkU$uf9xBc9_)Wuj48su~q@XrTls&J?Z zm4zkw>>-4ftaJM4cb&bv?}N1(!hjrp!;G{pVSqZ%J0V$-__LXxi{NjLp7Ra(+XutP z!jEmLS{F`htfVk|D^jK$0K|vXuafSDQ%#f+^59sM3cT}D6;bhwje#+ldw`4)3btlH zl@7a#KyYlN1kFkMH44-QBIqSd*6yk*eDu8a@a44#4QX%^%!zU*_s4S*$XE}$`+2D4 zwg%Aprz=JjWlYCIB<$7Qp6D> zO!^Lag!zo}ds$>XbKr%2>6Zn1TWXxp>f9|rDSvhcp8 zL{JiPW_KAVv^nIauhkzhCnIkl-|N5iPxCy$fGVrUR_{8l~ z`R_PnPTZQb;cbg_OomjQH)~2jL-|LfemNZMp5%y+Muly0>Rg7wuhYeY51{MeOdI$7Jf1_!dnU2P1ZF*bfw`ss>*C(1oGt2nPg)UJ}&JuLo~+1v7tRqPY@%U~y}%YF;#x z+i0S|6gb`kD?492s^C{@b8e&VB1&_IFbC5o3MV9#$$kKlY)p6uemNa&QeD3wUkUXG z2F_LEhY0^K`g&dE>Noua-{Q991+JlO0^#Ron6Qo|YKEjEu@TK0r2_a~B1YZbZq)l1 zvoI#nD5i5d_?ZRa#~T|B`sVd+>q)M?w5AvnFrbS^akX$hf(G-efT)lMbskJ?d)ssBDumE~$Pxh^s2hfHHa^ zX zA&?6zB(G;49q(RnV71!WzH9@U?^9 z>`RF4a9sFxOTXl-gEi9ydH;SwG@>Y@Cve@8|9N30^u9jZ9sEBaK(KUUdp1B&d*!+5 z-^Jr5mXLs_e`xEmDW5CnvwI-Gs2=dFr`HkIMhVqjNq?w!x)VN)ugBV2fwptNIEsnys zxKEo~4B|whzM6h9qB!yTh8cBLIrH7EAxH+>+|?N zEht=m0{X&w$XMjYk1G_fC@*)6*^cT~sRtksbJoj2IF%MzNnQwWE1i$h<8R zr4~U&$;q@&A}%&0@ZjTYN6g(ZKHyahHA|Cz&2d$EyzT{DG}0PDb+rY!QL`lj?ei;$ zpP6y%=MP2B?idy3ZB>q|f2WAcf0^J01BSGu*N;Bz4rT;EkAbl0PNTgUyH>3J=?B1& zB>raG$#aYAS6U+yAxh3#_qMSviGXN{-UJ!&^^pl~!J{6%-agZMVQ^^9Lk-A~TsnUC z9wG#+QSK)iJCsUAlC?b>+q}TvWs|pmE0E;M=pwGsqE)5yE&?UJ6J?oc7RywAd|s&2 z3;7`7!oq0K#skGX$d#`(MJB~?$_lC^|F*U?x}ryea9N7vrDu-*^z+Z64Nfcnvq? z5+J>;V+$P6_0n8fyfkFz?)X(|Rh_4)o|?#eNJ%Q71fOb0xKKEP09}dB=gTk0Hm1fT}kx89g{UQ|e_c4r}Ekzttc*n4PsamjCG)je7&m zlGBh5Tcmre;usRBTESuG zAOZHJ$LJLKb_&=4qG-Zyg0rs0|U@% z+H&EKC}p?i*@NaLxnJj1l-#)w<$Ik&+mq~5Pm;jx68-wK?9;p9xM4VX{zdA=IkYe3 zJ2TDBzq8@^SERN`ow*Qy&tjEUz;m{^NYwfQ$#&w*2AivMfRY9o-nX47^Z zd&%K8;ic!X@~sg#N2_h;1rz-G3+S_hw2APlj3p`RPV-b?g$(g`KBL2yT>~yb`!pf; zWFqxRWElir5fPU;2G3zsm>kRFWq6!yi(@l5W48~FfyyU^NPO#tlS|lPub@U*dZk&IH?=jAS7=AmV)g6v5 z@}->ftIH6Lmy~_mnyNu*K9>LiEmada&=5_6I?M>eIH#G*t~BzTw>SRBx2f3kMZ3wJ z%GL`O&aIj4_&c{SC;>GD{w9ipr2&@T>)3auq4oa#aT zRiqWp9oj|4eClRD{XUYDLc`F}&|-Y?6VXCTzY>_siS=?Ct7p2G_0P5rf30tOGsIbZ zkFUW*tM{?o-MR>62)`d9j~Y*seLoH>ne{pbP~P08`)(~Wm{bLUG9`e2UH3F^%lRg! zx85#n8xFihYt-+g!JINth{k2X@UZWvaexgCqebzDizl2{A|JzrBwnOC&K90saD@2j z6YuCs-ZhAIH9l?Iy4lnZ`P7REtR*)9U2cceXDjFea2_8xqloF+OJ7U?)o&rVhYqnz zw9K$!2xrKWO;tdliarO%fR}mVzJ`}Qe(}oX*RUw!jfVOTiEKhSOOTh+o6U>eLUZm~xuRM5f;GB!*|)D5pB%-bu+p=YI=|8-^yB;;?&^74eF0xh1{Z zOtC!lK3SGqeIsEgWWiQ*6J~2yTZw?wU{3o~Ok%CMGA&?m4Igl!Nu;m=&79eqeDM_0 zzP6xcP13HXU|Ng8x@lTIhbbfS>chKZ>|Km0H=XaZCqLbny=LX!<)5&VRBrKnRQNi5 zrj=)ckN-AmuE!!xp~xzEZFTZ+a0(-txH;yrx7Qd+*CP?tZ)lOnKhgcOp&sl8Wk&&> z-KZch>KJIlpemKXM6PXbqxi550tO$*6m53>hlqWd6o-dJ!upmZXJ{?7uzzoWR}qT= zO=Dv^i_ktoy`zjqQVA9>kCn25`RG|^s?wg-agtegc!yjEs5H-reufR@$ytAm4F#+o zxxj|HrZ;hlJXk0r+z3B( zs16X(TW-mK)KE*U!cK^gEuhe3UJ}(m{D+PNx!O*`(x4M_t;C#fuFY@j zRGR-=p1J6hO$i?RpxDRE()~3jl?VP>t090_7k|?DW!ShAqwxHXUU#8Ci)}hs_p;eS zBs}dZhj$s@W*bgo9d+}7ogVuQOAevXIxNe&U5R$lO<42_j)yerlw}7k26TGfUH=hg z@XW+aJ;Oq?6_Ng>rwR?#L_bCUWgCc`Dov^RGgRUdQ2%Xo5R{^!-3%eFWJKPI91vIFlWoc_B;&AOf-LuvpM%A9k^sM=D~i&SfygR1RV< zQ4|3ehb$izEVS$wYnSq8a1%KatJo{w2q~#RD-6xuaGVf0E*lA?22W{P6O>gRIM~67 zF)@7ziKUd5=1~RfxVDM9Fen2I_k~xh#1;b!;RS*nu>%k`k7f{voA3vc&PhkRM>71k zv3EO#Q?2d=0wx*b=?h(ZtDh0zK-h4}<* z8~y$lWL`&7jrjq%H|aeHYpsUg`f-*|;EkVw7o10H(sukLl17L6l-ln}Xa=U9ie{#B z$fIN>4{t!&Q{zCph|XZQ-{e(UlI4lg(?=#y>J>J1M_6<&bkgXhKg#Uxz%2*YyYrYe zdN%)$$gCZ1i^FYN!x|wjMlh@L_1ROda)a;jCv+x|G@{u^Vt~JPUJtycs>6 zX#h-L{H!BRz&2GTwra$p7$tisd@B-}gEEzSv~Y3+9TG4oup}!r-QM3QuJ|Y35xAq1 zzF2sw&7kePQtb-q|EV7sDxyc>XXsotCP$J&G1J6!9Kt_w?&55<<9B5ldL8|)J~eZd zxWmsF7_{>DqvPn&y)q;?&5S1JusdBYB659eVZ_0Qf68D5H{RRAHvjT**Ss){js?o( zPgT>DMVqi-M}GULwYc@O{HjHAoA67r&IKK+za918(Uz!j-Jd-ejV*1iaMB&61m)>c z!Cj^nTb2umpF{qj(|tqL&b;H1UCXdJ0_Bt1Aev}wma;{4VG~Eo34J}p>Ir>0BB%~J zCUl3&G61dVF=dyJ#hf%gh2#(4f5pTjhL2UE$4j?YaXrq`NZ+TP ziYd9Jq+@_s<=1eNUJMqe{aeBY)S~o{VN8nDhjefv>TKv7d4SMf(+VImkD8MM7ZKOu z`em_^F!DDfzwODEV8!2&(*zH@esfl6c})#6i2(yxhqCy2wq2Kp8C`d&m9C`lp0vVc zwTQx#z=j}IMwV5_l3g@vX^@u4ttPP&iU$FX^sf70Y;cJn94#I$ib^r^1@28w=pK&i zGk5QgLSCD5y_$s4J4Y4&24nEcPC2U+DraRw{9{%FOBwI-jl@y^a5_yjjR-=O_+T`J zN^b+`T+8Crn{ucQ^eaV5)PZMISPlg>HcD$)+TS2hw04bkr{8nd94s4FvtpviQ_S+> zoi>*cafv7btAn7hLq;yl0Osy_eb|JXrDAuGGGDMmyI9oX{IonP%(S)UonZ^fLur4v zg$*iyKVk0s5IXOL-hi`kFn4uxu`sd!hmuhlalkpa|LtNU0qPn!ZgQjeJ=gu21Z9L{ zEu#s5p@Gw1G;|h*$B0<;{HpbMyUz;Z7QNms6{r=3C=cakumoa#mM0s?f zLPrqe{iplwNqTa{K?U3RQZG&t`rNq*hMBP9pTl#(91~ivt&RBD99@WN5&hhxPH*6D zdfUj~KXvMd96YjYA}shRPhHf)68-yLg3f`%TfjIpD^$&|xt}~#Wd4Sv_^~zOL84T& z`s3o8@=1rP-ZS5=NwF}RA!D+&zS|NF^It8zHvX^XL0$8J}s`o2PC;3UNbsN=4htsgq5&7}8k zP}-DndZk%pAfI{Q5;=qF1T8?CL!O!9YH?V=algwm$hPM@}w18?}d?B5Fhd{dBvE*I|h!?ePfLRK4(ZAB~n1vkAyzf#WpG1e}e)_&T}w#0X$0LT>t z(BR8c3zx_hj*93PeF^WxDlt#kCY%qxPeaHS%-P#|g}iwnLm+-*u^Uhrw@XShTeS@f zJ((RMZRc3ZhFTe;ZI;9|+&>xbf3T_P{VxYAmwM6(q)|}tK){)qhY}s}?NfslH!BqS z^@r{?1*~i}ttKFTGA9Lu36=wgnpMJUvOH@xlz5h{^baY9(s}9>1uXLx|ILZmcop-( z>gKje$NdUWe^325N&%ef4uq1#(ijSi4UwQUm*fmou4q3(Up9i+i303JA}_;@fN5|q zW%>8yd6Bl^G`43@Ok_f#{!a~X0`Nf4!TueHVW^o)&r84PUOhyhZY?mL^0>OO2F?xe z+tbOA6pRv396Oh1x#3r1%aw`|y){UWymiJSEC*&>KnS)NmraQwGugl=Cyg`@9Uf+Y z12xJKD9A$3IHbh)7FCiTkamdjxvE0PEo%dr+p3Zcuy=XuplV6vZv~D%KNc86d@4fKG z@@-<{9JOe{HE76GyCYbl7Fkvp<@_`~I*O36PXSB5q(3W0Fa#ykQA>JXmm&iEZ{`V4 z9Jfm5a%1-PhYV9Y46j$i3>>Uk_J2zdFE|VW8CU;kg4Pn}eBe2mg4*vru4(m3}NHT=YFT({6fWRsT>I0f_7dR}BOy}y1i~l1U(%vHxnbVO%Oz>>uLXDu6KW!o> z{Pz5%XFzlIt{0FsjKx6^VxCHl$no=UgHV)3+cjuYui;qjA}vOmQXxv94WX0qABR^a z1U@i2c_C0m9szNX_P)3!Wo7_MFp<$9DTsj6?oXZ>*x2ZsR^;#F^lx48K3*~fsVZ>w zHH8#UB<#J@@Gt0!CR?IpZh$!)D=QlZ34nuziKVF=kOG<9#5I z)w58Y16WbX=5jW&aWpZp78gERd1cq~^n5xTGQJnpme-TvH@*$9ip6&wt9 zoH?QHyxhu!VQ>w>*|Xwl_b0^9>&~y8Yrergp;n3KlgeLwLewO#uo0o^ds50?NXVIU z@ZRzyY$0zs#nXq@WU7q&n3{ge55HI1B<>I5m0G_4eQ{|WL@{HFqdyl($tTz%rx@aZ zcQ6e?covz}6qzXpX;QC8Ykp7XR~;-ywzoiiWmubV79+Yt-3)ixiBcV&k((U(r_KH> zI^BG8PP1PwBa~7Hho|mgwX$d?Jegh|pDY*KTb_xhU4LP{Y1{02seiHN?!CVcwN_q5 zw|+NQZ?-x-J;>d1i9^$URXV(R+*RcQ?vS)UMG>$E=}-g*ga@h+?R)ArLRF3b4)*?hw7K#$9(s4l&WA+x=|{~dUL zS6Y6X%!!$xOr|SY3DJduJrxn%uwa$vJ+Qsa%VU*Gwu{m+ES|~8A$vS)u+dcle$uAr zqbH1C?oVl(HqkZ7bfU|tS&HtRn=NA^Kd!VbaU{;=NI1Z9o@xWc*sU6~c-T2zzx9Yh2Gfn~)fkXgi{h_ITFU_1D)M6!^xO^u5{I*Hru`ldodN zWjclM;FdxZ&DP7sS~8y*IiGWYO)`9(a%CPr?p$0Pu54@9exPNW!*AEbwU_nq`FyW@ zFZSH-MkpK&rUHokRl3`zSH3j}2Xi^PGu9AxcR3Zf);?!V2f{43uZ_8G@LT-Zh_5|v z-_Wl~8WiGxxm*yM;bV2BX)Bl!MoF3R>zQsy?2R(U?7ecgzjW4e{2T zvTgP6d*1fSH|bF$a`9yuBw4pd%+-{Z7cQ3;+Lh2NU?CEM8dR17a$W&^02^*i>$D62 zO=?j<6cE&Y;(+Y``Vszb_F+%X5hbGH0sJE&Rc3h;S2vRX!D5c?4sIm>p`HJar~t|P zqSS;u9RFo1Y2)%A+yAa8o48o~Gv;AWrV}Fu zn3aw$ zhlg3l!P4u88XC7Pu=<63N+I44x}p2yf$7 z4Bc}gCIrP2L5e^gf*NCrpc7*TmYgvNuw;qmnKA>7)*fWjtdAmCsgFbaVG8e!p)3w- zp{_i_X)QQ?+UWy>JJM($`K*`w`Tu?2T;0%3T->}| TEG*$zIl0)_;V3C3lqCNb0Zx5J delta 15269 zcma*NV{|4_*DV^`PRF+G$@{a}1(jPS61g*0mLY@gJ70bCKn-LCkn_ZQ=@%Vvfd=fyA`V;aTwZO6Art^enJ zC4bf5k6Pf)*9H@CkH}5TA^d5_J@T)<S7_&~@0(U;h_m z;3shKeemY{ab?dS9Q*C$Z`fAv-DK#;cX`>Y^D)j5>?@42C(S^ov|2U0^oyB!q+_(D_NS6R-3W! z$?(>&i+38LSAWY<3=?M7l z3rQrchZc_0e?Z0(+ID_?smoo|`K9U}(0awsQev5do^T7<9Fm1cV81nTrMrDvZ=zbz zYBt3 z+Uc%^qfD`

C7L`;6OAthntJfUG!h`fU2#tY7c>+KZy{5ii8GTN8F$*XE7yc`#Mgj(3rbHmQTkPaT*si{2@iN9ccu@ zg*Ssnqx1hT^Jf)RIv>KsSZ@N5>RW0^X+(g*-~@tHg*Rr@Fw=P{&xQFclQ1lMr>ADu zGa!_XcGh8tEW;p-OvLpgUQn(1jdOP)umocq`Ao%-gap)RLiVy(4;=07^z2u4#TeWY zds&IpG<=T|kgO%u!cEUTmAnBFhWKD(OMK(zko&w|nK3=i+;fjxQ5-Oc1)Nf|pYp9V zaGifdXz@AP59EhXb$ROgASm`g+S4@&A8+Zf_{UBwEoeQxO9BoBeI_zD2Xp5!|sk?=P71|Pg68igkB6JD-wr@RuX-;yO zrF%OLN)no#;|cE;syxj5^j9w=OqRGR``HO7OuG8b2Tt@n-jng9)Aq=&4w{Az;XG{DlfFps;(hqgYQZrt(rS zj9s4 z%?2g7Np_>Ym^py^9d(Iu*#OoIW-e_&=vU4aUxl%-Fz9!)JCOq5cGnquV`Mx z%*}a2i{%DhJDfXD_^K@ur>1-CGu&(}s>EX~ZS24*1 z7&`2>Qf3TkOLa*NU&`wD-x{u(eLV_zQANxq!v2A`7An5$<)c#7+@k2}-Tb83aD@R~ z!$#Fh7UT=WmDDvfnU(dmhB^68(w$q5v0F+9n^o)fE7Z@kRX?kAs*ED-U*bZRy1a`5 z|L{C5IG+QTg=J0_ud+K>=;yVbs2a}C6hKF2&U3?--qh74l?O|S3BjiZ$c`!0*9Sa8 z#A$a>E6Dr8%rPd)j!HPzJUeuAjp>{Q!1&)=Y(ZFn1=`IjvYI4! z7M7rIVuE`3Rz(c^616tWAFC`@hHDgF(%`cC(=7X_|*mE`Az z)Nn;r!ha9QIq6k~*m_Ay;hFD5;xvt9@Ao}xhLpQ{*W{>_{GdPiE1V;-CW!ls4kA`7 z(g&~(tg_$;c1VX})v{h!++E>Q*Zd^rfmP!#{SO`3FJzeo^ka$2PH9nX8_xhijp-;s#8$m^bgU}RCK4E{R zxq$7Wrkqd4NVO?~;OD=1&5&j9X4jgZHWNUCM)7N$D6vBttjMCt0x2;K%{F>|)NtU`+DB5$|uHvXnMj>|W6(!l=Kx;K-;$HY40inw{ ztd6TJ2lB)XbH!~X%OFxIV~{vO!ecAYnEGHMi6L#qVcFqz0Y9EeB_1l!gpG}wey;&Z z(@7$CCD7nOWK9#*3eW7K@>$#&%Q6kV}DoiGawyFwK}1+db)TN{>b z9Nn2-%KN=6{B$?oA3r(7`zYyWV>@>*Br&%|VqRp?jXSc83bXbIKEulq|86PxgycNW z7-v3x`7sIDGF1l^fPS5fY@J@}u^0jUT%T)3hD24*YKr}m(w23{k*@v4C>k9{{qo^s zQlpv-m2YP&>{Av?Cz{)gCQPy}I%Lx!J;l(B8GQ$00Ny+75Za?hnqE?K|a^b#R?X8OH1f5j< z^E9LY$Z+2Q{p>YB95<1>$2Km{daV~&|1AkZqW62c`gczC7KN##-In2r=fU?1H8CCd zk8Q|1<{_f_qww_?wl5*W#znd+%L2z*GWsK2B$IuF0}!-W3m~!Zb&KOx9Uk_+5~-=! zR3ti4ePv==+a{v~A3)=jgJN*DO+4zi0Y6r`C9MTFg)UhF6yg+fw>TOXPd-IA&s!*U zs`%A@Dd`hPZ7Y<0{^E&9kN5}w?&O)}#Q1NT67QQhWY^jVN-wz`q`#r7(m^c|1QkG1 zqvz&#=+vxpBA}{$2u8U-_zPr$F-yf0pdOVV&U!j%&v65c06O3_n6Nf{8GcK>d7DM# z5uza@oqjt+{5`SD3rb6`Jjj5Q{WpKTZhk_)fXiHp(VxOMNWgs8@w~c8uf4ADN=rbK zZ@kcS(6a?`_X5>|7E{Y~Yke_*s?CC=ZMg@=Hbk`24oEXKOM@4~Gjhg$0HSL*)lzu8 zG6@2n-6E&S?xh#AuSM2U7r^1CZ%ZmFO4OYg2FC5U2lA5|bCn!*dTK7g4LuRQ5;rf( zr^Tqbodc9wGT}VYyxu~Hq!jzd+ixR9zmU0*W)BC@c;ER6Y-OFCxE@Sy(e!a;Q7A2d zTl}aBI53sipp2MmP}0WocDSx;@^;OQ2(DcY;ZI*FQQlBOeQWCbiah?0v`AI1B)|Gd zxQY<1*`Ky*;nVT1xdBZ-=m#|+cqv&~jd(dBJLi_Lc?4IF9kt|d=>km6@SlI7Ru~OM z&E(W?=JYv@eT4pjkyE6EQZtvXEv-*%`theS#sa7wDu<$IZ#^}pp?I!@R5DnE|9Lamfu=dKEujDz?_P3-&gDu&%}v{dbM zmSbS>B}|gTn2UOKI#;5Un@-_iRJ{uR6~pB{J>P;$Izt9 zR&{iQ?pscQJ+_Ruamx{}sD^7XR@ntCS@5MdqZIX7ib>d5huNtYqq)ohi2_mxPufsA z_$wZ}Tda$_3f;jSU50kP6DUV|^t4Ut%Q$c#(53Lpkl3cIr4WYraLyBk45TE%kfvXq z_^=#aZGp~_rQw?HZsaQP6GH0{URuTPAY=-4fRa`u!`w$jygJHc0QR&wj-FS(P&_6udk4(d zC^d;q{i0w)!nvo9TYwu*P-m8kWDLeXG-bNyg>!2?oi}+D+d9D=F(VB@iG>^jQc@6B zMm2G`Vm~5NQ+;BdhX%K>X3n<~R|JSSQ*pwQS{eZsddQtYRGS>7_l~Q3rp-YK4xP&2 zqVAW!ou?9fz)tNL_Sk$=-k-&)Iu%+7e=O^kOFHCexxA;FWAXK|)-WNh1uN+PkCPby zH)v}5EMfb}f*S)4f25{{g@c@>3XWROuDz*QG6Wl#+TW=8_|G@3+X&h$+5@FkiJrGd zlQk$mev3W%dyDvf>z87KG=6Oco!HLZ&741?!wVMfH|D9;G2#2|99IQ-sCVZn-^M15 zf4o6-`nY%dLvMVU22m~JX3mm(vR$j6>_l0;NJZ~;rtv*B{t!wmU6ZgaoLPbUUt1gGj(dP?$xeDSO)$kZU-f*{%tlz z{>#=47VS{IBB5b=&T9BOl65(H{pCdM1}}QOwB@N?q@wBr$o{9=ejxZ$>BwtK+rI2l z+^P~pHBi4H6(NjT)x3cuEk?!L?xli!fr}1*1{+MGwld++RpD_e;1u`+>!R_x(a(4S zmp3hipRMTdj4_!C;P1<_&$`%540TK+nwAW%<$*TP^u^WwJr)dmPUkv8bp)pjKf|XY(%*BX;NPg$o=CqK zbA~h|iyN1JPW$0T8v@*gwUBAoxX)7GO$jIo0Kss$tOUgUu@bva2pZO{qLJy|Ip#GGi&TMjtwpfS?~7)VAI7uq~7?*2ohN()FhQh%A%GtkRH>2&Wz_O%O|gk z51YQ?>DfRMBJ-(GIh9pTG>{`P&dn3RJ|CY86A8$Txq#KbU!KYhcudUs_}9Mfrj#mA zS?fr4_#gLaJOfqjqRaJ9r!Sd?RTJ;Jnd@FSOSJ!Pn^ZWIED@?y|Lhd`rDvw&Sr#6W zt%$C&rl_7?THKU9ieTt~kAV>Sin2W&=Il5Q?l>7K&YOb0$p@!(jx4m;*1VZXZxV27 zPDq#34eMwO!SEUb>!meTT;}cQiM&+iyGB{r)I9XE(F#=VSV;uk(%k{Zv~RZv=Iu%O zNyB1G(nl+?iP?0p7MnH7IUe?2*yxQQB<#Q-QRoxH4F!d6EL8CAW*Q_~v{dJ4n!xF- zOymOS_^g_iThjAyeTb6LDe;n0r9mK^f}`m+N}C6Tf55e_`Ih7n(8sD_cen=WAA2^8 z!DTA#hXX~xO>MVn>sd8d);vUJL>p;OkeQ2B+M9aZ8Ld*jJ@T2ZgjrctYnOhlMp~;W z%d+9BQJ}Sk5Qpf?!4qt)zoU+gB|0m;kuh_y-NeHK z0tu?QIIm-4h5CiBVq;%&6t@OSy-g0_N#c%tBY-A6Ru*_Uos_Lckm>7nBhFw!H6X>3 zWtz$8IwQP=bCm$}Y`!j+iJDx7YFi@a>K4uxCyvTU#wD?bE%WY4Q)c1i-lw9Mk$=1J z9HNhTPU}PbCYzp%msG)M&VYviX?ElTFc)=iuo zwbM0su(*-N(t;^l1lM_PhLWDBxbsNtRE^|0-t(srXGscD;qL?B#6cHrQ4W=3sotv<$?WE!n}qN_omS23eW~s0bvfSWsyeVkDtQ8t3do*8SbmY%L0H~7f2F6S;IQ!` zu<`8oF(+{WswQy%CXJ}RzFf+s*%9B0X9qvNu#@G(F|#1JV-(fW)Mj2QL3wwYD){2WKz_qq zy^NJq3M|_))Tnkg(jvvqEgfA|J8g#f0;=ha3^XB0ClssXc(DfCZc%JS${uU}^qrZ5 z_QYoJu)KpEa8L9rcqwk(d;hIAxoAK3GZb2-)dhi_HD;{7y5j&xo2JKECwgo|A!1AB zi&wp(Ge2wpMDpf(F|AG~?gP0V-|yhvRm0H(61cMuhh*ovg53T&AFf-2^?uR0JoIV@|VO!aec zMM2PUtiO%QX`{nsL`WlYJy}UtP{o_TRGcOKVf`-iIfPoOoH>BOB5Vx&ts*^8MX5&- z8R$yxu!!#-bZ>N4hIiYvpZX>|N^>RnADQWee=hMgR7B)gWrRAM$$)r0tc{RGn#A3GYzec~Fh?2FJ3Mkzl zci_KFWM?05CJ@xOrnyi|JPiHfz$fM8fs+f0#e_c;iuIqepiFm+dnXkvhD=0kHOw!0A{| zi(i#fG))Yrr*Eo*=*KGZcF}LeoiTY3^IcZ`eTbK?SUb;TYgV&AUU{{^1zNv?T{b%L zz2SHk+tM9h1@I)=$uL@hSb$^*NlJjoCY3sI zXN_}+BNhFINT!+O82llm^PR%cKHVR!K;|e-I690xycI|`hu`%bDkMPc6V^EA1Sj7e z(jp9QHetqDZ}w05oq(TE8Mqc#2dzU)&LP>podAj30uQiYTs7XtJU z2giPxH0v?j3+HX@I~)4_RDpwNE+WNa2t!&bnB8PW==4L^fHqe3I-_y6g9#LEme1=m zkmRPb*+G!<=-*BK7~h&1W-$AfW=FFU<``B6{cri}wXfni8pgHxmaJx3lN zUf)%9DAxugOUgd-*}Z7@+T|Y-o7_)d^&zIsboGJrs#S9XzYbBc_9oZ1ffG>ai#_IM zWg5owxCm*dG;MOA^52vVf;18G7TJ0K5r(q3mLE+$1gf`gh&KQv53ZoLXfRV)PF<5AUloAlS0st{!=t`cv-rTa`nds@ zlg`5{&_&c?ibYdsD-L;S0_Bz9%(_T}s&P~L1x8fKNhn4p+A<+{GEy$pcJ#_7&U7{S zj}jhPB(KW#5;u1i)U;s|f1K=43e}Yq7TI@AhhBbYw=!~YL9vLJFA7aI+tQ-T8@`M% zs^H???j+A#{0nIk`BOQli^Xz2w@*6B1gAs*5aEjHdjvFOBMl&skjy`r_wAm<1S+y>7T^XZ0mi%&!yih`?ps2EO_;(~!~ z;oE_CIYDwC4dpr*%zvy3Uh=zW4FMoJ4j)<8g#xMPi?A6CLy~QZ-y)CpCagia5I?F6 zL<-Id>)JTA8+|l2X4X!7^roDo`hd*ZVpD{Cj3*GUy$DBBJl}O4lE}m`s2qZ^dd|c- zfIm$wKNp61=*-7by$63ok5NPc9m~v^$nTeH2!ZEWpe&-3LrBD-hYQPDpi?fSH4VYT zybv#~h*bxecsCy_COh-vHO*MViBGuMu zhXRJZ5SOpTX0+iNr(^r1q0eao|k_vEHb+e{qfCOSfLwGR0k8%(iN19{8RJT7rRpaER*}E z8PHg~H}49S(bQ-40a|!!;7NVP<_O}RE6VcA<$(ttDk~f1VXxG6+68MBUcC0PJ)ZA- z4{5<#;Cm0EQHMX$#r6=`fL7$USnl1O7)_IR5v%JB>gRAcaK)b@CXv#?bxlz2w|I30x+%+c2lXYtDiu98KO?_2EL03Rk!CY z`z}~bPXnh9Iw_ZbN4DctU^S2~&yIX+TbRsFhbmM%Tj~AYEn*!Olww3QH6qp;`3(K_ z(?R%Gs7$w9v774CVmWxXh)4yTZ&MH!wO}LEfrO6!>8$J2KaC+)jYet4UTvpFe40o| zXOI=bDojIa?fBE?a7F<11Qy+Kq$hpHf~Ayk{4WrSqTOLB?ZWKljmCgjh=RSwt#zzZ z+&@~pCrKK7Ep&ieu%tU{$ACricOZJ!LPb#j3}kMGJ~9-%3BFH?K!hq4%4J<qS3f2f_L9V&n|d)^`H!reYb&v$5qC8jNa-7o38^NQ>;)OC3amD3c^n959F*c-5gkqA7I#^kU?Ca&#II_0#a;J zF*@rH6Pli&3KIxBW9qmVOsQRNh?gX%yu@oX441eM{!$G)P>pf^<%)C@y^r+jt zltA1}y?yK7VjF6fv7EySg!4GZodt8;&SQOFwa=^cOA&IYvB*m)x*+Fw3yJ&%bG4kw zOoWD#RkdwXV?L9)!QoB)nEaQyL4gmgM>fSPRv||Z(Y!~Rb{%JK>XU2d3dD@P%>{7H zxG1UuP5GpWyy(QM*!c=PtI6rhO>K*l!~Xm9*Uoy_GI=U+TeIx-RIo{vYY5fTG(;Ff zD}_Gw3#bY&(uA!AUdL}1T#r2ib~IM-2l*>8jQ~tcMcT?!tJ1h4i(2JAJ9w0G_NvYV z273Yt1JV_RdC@AdKXSX3i8{bskPE0Rz<|hF;=Rtga;+9!l%XwkMIiFbVrrNZK8u2A z*e?}1KE0J!G7^Kez8*7#?Uf)@JE@R?bi>KE3vbA6S3RO2{jO>8+5j2KrJCb#`LXCp zZF6In@LTcJjv0DKCcWJ%)q5p#k5JDhCIc^FXG7v9uh6auS7-TVU=E0PaOkoc z9?m&)^+nBO<>_%QsWq0n81FpRH|(DxB{bnAH*`%Q&PiqQ-nE2en0~+F`RkIi{T`#@ z>|H)D&s$Be{v}Ed(oPGOvWkHYa#EwY>Jb)j|2&Bxp?}AWSyw}h0k;ZRF9G1OcaMWL z784ZP*h<0@e1VQ)G)=cwngVg(AyTIl?V?<6EEw|^J~9kMZ1Q85Wl%AN=rC+VwW1pQ ztJ(AW+%Z)OKW7ZncGjE%K#9N3u-2)d{w}1TXe@Nx#`5f4=|Yx46!82_ATXiG8n?D- z@RnJExCBpDnBWLy8Y|qTz-Oq>U=eM~ADMEt=0vADf!|XrU(m5b{tGuT_5U~;(pSNz-aCg;+J>VwJ{vOD?f_EDj7d))s; zI_Glq4M14&p+)t9eM%87TYdR()BWo!%W7+RQn1iHI^1-9gT~z;mrxbb1~HmAeW&8d zebsy{ynz`ls$eyE8Au2-G@oAFW?q3oTt82O$C5I+0<8v4&&^@l#cbV%D-f%>y2myt zuq!`oDKhOKhgzuM4NG|N+cCvuFr2J3=Gq1R0_3@&xUF?cDwvbC;aM2W)eSG5p&H>w z^1wt7*yy4LF+k{s4usQ{b9cx)iWf4kbk6!ZE2TVz(tAsu7`Uc?zhTFKX<7bDVZns~ zvxS}6a@eU__|ZK?wxh>}Z=k()R;6}!zD=;Z^)knM63JxnY_l68Phwdmbr)}=A?;tZ z4aZ4`h64e4iK=&2i(XI5N8hYkqs|1^2hv9e(x(t@z0(w{_M?w6!>-aLcr%9R=3tp? zkHxvsgN#n_6u8it=(j?6d>p1?bFW%^y2p$a>s6CeWyh~lq9dqi`PSwyJSW->6ALTJ z6)~3zn0}Q)T~BRP{5f;Ibldph@ItiTYj?TlH8^9yb8k%4{_uf{8Wj+4SEg^~@HnWR z`(Brdhmfzh@`eYn4u!~IC1CSmTMJB+r z*{_k!&D!RVv62VrDt7a@;UJjS`g-3D8`i=eUU@$(b0)>-tfzM0RICk;BR!dhJ2z0d z{5?Dluh0AW=abp;i8GUkwypThh*|A#e~7aNnVW(vfF$@kxL@@P`e~(l8d>Q5h>KH5 zH;lNO7uc)xAj)UX*=pTrGJ^opi{YR3$>&NERSBK8ox)iDi%nxog=9o8Wl;*dHlKR3 z6aunMgLub`EFT|Hn~;CfcEECvKZIis{s!$yz3VukC(?le$Ll&p1PWQO#mvaT(#2Zb zKQ)ZgE(eonWv;{{h#nd^zfQcf3fsWdlsJ2a2(+vD-8?5@)15!9VQbkqF6Vk2l);@> z`wO05=uo+P^X#Tu|9ZMf`{GF9XD5Z?;?=-@Lk4ZPcvPRhS?=aUS2#-}*eyz~JUW`e@7$;lD|%(g1QKvfyPoW`PEZIe z5TG8G+08B?{1OQc53{)C9dY*HYDj*ckr9Cq7CoBYy8Lt;r&xuqPEkcEgn)^RlEKtX zW4gM z0=n0GV85skbK5^B#-Pp3fZurZJExBbz<34)fuAcr1y|o)<{l;mb~fhzFLIlMYN)Gn>(=+|;7yB==0z$K9y3Zj z!ae6sf%j$7C@Nvf8)W}vmG$|F;8M(5{-QWVm32R7p0f`|S7O0(gTj8|W4Ak4pxI5S z=l~Nt{W^8QYyv~2rRfA-k~Yrua6OE74cisY6&eKs)`u8Qz?0QG<7}_kuj)=QnusYy zR{i{P>rlZ4q{B}4Crf_8g2{KEac?%xoL_T)fJBe+B}YRc4*wBbs*BNqt*1hx1zai% z_bJ_Y@$DXD{9{4o%iFQKI*Zys)lB^(YE=r#lV&|@9j{Kbg9p7ICZfh3fgw7y&^et6 zXy+UhuG(jRhG)PT-7iC+*`#1`K}{g`{Xz;F1f=#-`CGy6)s)@D`>yPuBtQpb04}m4iXFLl@U&tdqK4!v|qRSW_5+x^1vsSUNGuqcDL5sd)yVf-8Iq( zv1W-B!_=u$nBZ>E$9*7NzsMya7)t+z!pEyG4$X>1T;N$ay11BVCAa1(+mvXv*x`$OG?1MmqhO^i@N4o_t!@Hr?m z=wjfg18_mZ32bzRrok5a8S9?+(hS8>zb6AAkd*=_2hLeo>4VLHPLU`7-hH-J?#Gmj z7!#0i0{DCgJ8y)oq*|VhSjhV7>k;kt>!&axZHH4pvd1ZvxCCBXydLE2z0~spGWd5x zIOxXmL7t11Ia9(FWWz?f30ZZ$a7@ocu~c2Ivy5xp7Ef%Xh!9R=!u$c1X#i*q$vGS= zzN{)otO8kL=JX&Cf!bhRf1E+}5F>$7W=(A65PB-4(MrG|%D2_42dvVCOg&@5*HnEO ztdtaH{nw>)HY2-CTgMUL3CUy;QI$gTG;$q|m1lmAk2k%$BaI!?2QM>?lYNUaLqi$n zL6m|m1il4{)Lx0wEn4`gWFGpSsH|wg6T-vwU*NhZb7B&xp7-nEG(YUE4;|lTtd1?V z7VGQKk|4-8KPVf4s5-wNd+^@zN8VQ7l=H6~IRq9@5u+)Fy!zI+q1}=4gEsOU0Vgb$>-u4Y(KrVWNXcbyTPz;Bz{>a^ z);LkNeHQ(5-X+9;SL4roeBVU&Ikl0=2-C|dkF$V?R@z0Pc8F@2R8TT=ud6r{$##2) z@xGO0P9WAVP@CFS1Q(N0R!wg}?5L`BNS*tq2Pg1d`rzI#V`NnBz(xF^fkEb4x8YpZ ze_%@Lhe5#rz>&d4B%GZ=Duq-5?xEHC%)~ZL?vU0oxbbx49BToNxtHmae+(o47gSJ0 zjlK3{6G`JRw2;Hu68EBs1Ei<E9wCGE4+!O7Dg>Z!JY{x)4O-<;~CuQcEGo;QHyu03QwteM< zaGdc0aF;i`7(e+j{P6(!GUE}Q+<4`D(Cz1QfAn~ROvv~4VE^~r#c#=T>G-MBWBUWS z6do(&oc1l*@~>#9Oh4p?&URLI=soQ#i|wTeV%cELdAm8t1Ai8P_4^= zfJc8&opNYg7_SbCw1AcC%B<=OCjHYNs}I;3rN6tIEPn~7;?ecx&m&2tQ=WYeS8HGM z*;j10vE3}$pV2KEDB?}D@P^OJWh-n1$c;vnGUjcnqyl_&jCmvBc86{t<* zE^p;$pPUYz(FeqYJB9j1ZLtXL{?69PIF$7JG^v(hk9zm+@lSK8$ATke!FY_q3i^Nwe1F4vN|o{klg=vp&P^e_-vob5#u%QVAo^AP}MEx0_Q z-*zg}ofUTQd$)aK>6fdg5F%B#qo~1_TQ#YO3$tn^d1^JnD`;f^J~EcOC{5mABtJUT zquptxh&6#w6a<}6`JB?K5at#r@hC@*Np()J%+TTKV;KU&+9eqp1)6bM9dU+Q0o&vQ zzM|K4=mIdnEHSID_LO-p36TJ*+>PP4)Es_{Ht|$3OW_-+GmNwD0#6XjoR@5BX&^IS zmgYr$%OqW8ugttsy1&q*vk}ShuKig|n(OOC@wR_f#LQs*fePf+MNLoQLO+}$&Yyt? zvW859GkfS{^puO^vT-jf*bTj&>STPqxP9t@oD`r@Rv~{GQR3F)rCl5;iMJ{hHh5O! zBV#IC>redX>4;NH{oC!ZgT!}4$4!B^G&|{ky`xu1GxiTKc0&&9NcBr4O;icehh)C{ zXqEJbA0&3QT8fC(cH5Y!t=4E%da_PY?#w;eu4I7C3^u|x^NM$NvUsk(UdZO)y z!vWqY`a``l$cP50(6z?BZ9LcL*Hnco?%Nx8Vu(8>%&@-13POMm1${#f1#N>UbnQ$Y0(xAMH051xi9&?m!asw1$LgU1wq>fc^mN6?r9|ETjuqrc>9w|JH2^+|seN@i`U1`;!z%^}` z0HsR}mr*<#YWr~{Z30Af9BF+-!fzOi`jZXn*Ol z8POiw6Z+pwim8|{RWo^I0aZMBk5BDu?Q8h}GhX16<_*A@ZcS zeQyh95}D~!gopS$Eb~WQhWS5(m1_JJeLwKF5}-5Brpjfv4gm?eiLJ3h1rz-d692J1 zCdUr?Zx$8V(@0+X9zb7(3kW0|9A7^>KpQp<||; z9AGvT#4+FyjX(Vi(@1=Q0p6`Qpo@TKjC~YkgwORGp`m9?xZqiFk9`#Un?~}W!QCuz z3?KE~msx;8zGn*YXrkaQjlg_T$vu5WTG4_2HcwQ^)F5M~6{z?_MwWS*ToNh$s#B+E z`O26;tAL0#tmmeP2$Xp1gcfbQ{waq33`vAQZ7q&(c1p`fm0~lHY8as4`n9~1^-&We ze&6^-eV~_0zy3Z>3gjOfbwT#@YfsR(xVQ3;PzJFlpl0UQ=_wvGcZ_DY03oLV1DUBM z;7Ng>E+W2y&jCvh?6l(k_aDy|Ygb0t-&V>Qy16dJ_dbwZZ^%Q2adCAvGqQv8%+Ar2b=Z(V>3PtY zsuxFIe*uR9!;s$)UGJ+))(^tR@Um-66&ESWKHhoJxvn8s_jmL|Nh7rgdzkiT;}fNc zNK7&1krvm*Ny=<%CP#!Vl-j4={uc=>rlP>7k(hv2&TbkY9fP4yicu-T&`MXztOBwX zA@Cqt$>th49TrAV%^1QVb3ZT-M7q#P!E=K!H4VX;A%>H0SM4S4Cva&J95Of|<~0wV zvsNUGVKgc1n2gyiqsV3{r zSvUbCgv^nnZA{XZea*CCFn^6^oq@7g&-5v%)O!%ugSo+Vp#n_oJYtzn(gDP{`o|E` zNWdJi7#^w+3Ai!vq?Dh8PVqlIV7dS>1e_vlA~Po9j{KLnLwJnRlL$t#MdC{Qmpn6L zgSp_0$UcPqPpily#18fQUub*-s2bz-;Rw#FTCi07?ph*oqYNsJhtpZZv%rhJaG{Qk z-1X`B=;*S6Q|G&HwiZxq11#)Tc728#tls9<4?Rua&mP3+N~2Z-#NL|0uZ>p3DPa{1 zdAE|c8b42Ed29QB(6PK%l><-qq8vVpH*VhNxVA)B1)BCY7Qug>1@c={1zgN=?Uz$_NE8nuW?Eh-** zE70rl?VlKdx^^ZOvMjDAi$0WHHMa^g@)_tbJJ)HVE(vD;fm<$=VdMjmMJWkJ#DaB|t17)1Kwx(5@9-5n6Lf!q@ zQK^>T=zHoErxiO273Al?wtt2YdS%A@@!BZ++2uTh^7TZO^rRrFCA+9-*o+?|7nWq4 z9Xp4+e;+W0AOrVy_WWKIJ`eRjlayp^?++Vv!i9cqyq1#)a@XS?F?HHYx&M7>I0dw> z?+n@xd6Y?%Unrt`5o$jiriWMZZykz|aLu$QPswXt8{MyEDi-TfyA+R0r(0!nu|r?T zI=||5c5N}-xcDw~BY%8+v*8I9n)ZMG38u_LqK-Xb&yaSdkxxzuW6hl6Vz>IBX&y}B zmGv$`EnMW7V>@9eb_t{mO;tY2m%djvW;2W9_zNm3?=1g6$oXHy`hT&GH5s0dkcNes z?H}eTGszjbxDx+wL)5{|-j$g3|3*f(WJNwI01Gqwf4wEFod4_oKbDe_v)Mm?7G~D} z7K)j2< z1CZ$U;bP!XdZ#8a9>`01$5C0hGCZf^BXF`kiZ|7Y3J;A#z>AXts9u$;pis1lZYxzE zZb2Mws~3|Tk^OLJ-BC`Bg$AiUf35^d4io&Nc=8vyO}`Nr=D_vSL5W_Cu)vt1AL>A& z>mVjkLNH-OaMVe_Y*0dRss^EK2e5{W@X!)MFfRKTFGYSl?|~qZfJ-_eP$h(_jENA! zIYNn3_w%IWgJG%WLveCDf-)}VgZ^@aly3{eJ5ArZsO1IPOlVr|^hY~i48)HK(QE?b UlV<@0aNL|+tZ)<*;))Xg4{x+LqW}N^ From 368d30f034156d9ae376dd3c0998fa8d7fb9e17e Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Fri, 1 Mar 2024 18:17:31 +0100 Subject: [PATCH 045/377] circular import fix Closes #29 (#37) --- backend/project/__init__.py | 3 +-- backend/project/db_in.py | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 backend/project/db_in.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 8b9f5f94..1923ab4d 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -3,10 +3,9 @@ """ from flask import Flask -from flask_sqlalchemy import SQLAlchemy +from .db_in import db from .endpoints.index.index import index_bp -db = SQLAlchemy() def create_app(): """ diff --git a/backend/project/db_in.py b/backend/project/db_in.py new file mode 100644 index 00000000..9cbda056 --- /dev/null +++ b/backend/project/db_in.py @@ -0,0 +1,5 @@ +"""db initialization""" +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() From 65bb056cd6c656827fae99a152783a6f8bcf5a38 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 2 Mar 2024 10:51:53 +0100 Subject: [PATCH 046/377] Backend/tests/add sql construct script (#43) * running sql construct script on postgres container Fixes #42 * added the db construct script --- backend/db_construct.sql | 57 ++++++++++++++++++++++++++++++++++++++++ backend/tests.yaml | 2 ++ 2 files changed, 59 insertions(+) create mode 100644 backend/db_construct.sql diff --git a/backend/db_construct.sql b/backend/db_construct.sql new file mode 100644 index 00000000..d0884c13 --- /dev/null +++ b/backend/db_construct.sql @@ -0,0 +1,57 @@ +CREATE TABLE users ( + uid VARCHAR(255), + is_teacher BOOLEAN, + is_admin BOOLEAN, + PRIMARY KEY(uid) +); + +CREATE TABLE courses ( + course_id INT GENERATED ALWAYS AS IDENTITY, + name VARCHAR(50) NOT NULL, + ufora_id VARCHAR(50), + teacher VARCHAR(255) NOT NULL, + CONSTRAINT fk_teacher FOREIGN KEY(teacher) REFERENCES users(uid), + PRIMARY KEY(course_id) +); + + +CREATE TABLE course_admins ( + course_id INT NOT NULL REFERENCES courses(course_id) ON DELETE CASCADE, + uid VARCHAR(255) NOT NULL REFERENCES users(uid) ON DELETE CASCADE, + PRIMARY KEY(course_id, uid) +); + +CREATE TABLE course_students ( + course_id INT NOT NULL REFERENCES courses(course_id) ON DELETE CASCADE, + uid VARCHAR(255) NOT NULL REFERENCES users(uid) ON DELETE CASCADE, + PRIMARY KEY(course_id, uid) +); + +CREATE TABLE projects ( + project_id INT GENERATED ALWAYS AS IDENTITY, + title VARCHAR(50) NOT NULL, + descriptions TEXT NOT NULL, + assignment_file VARCHAR(50), + deadline TIMESTAMP WITH TIME ZONE, + course_id INT NOT NULL, + visible_for_students BOOLEAN NOT NULL, + archieved BOOLEAN NOT NULL, + test_path VARCHAR(50), + script_name VARCHAR(50), + regex_expressions VARCHAR(50)[], + PRIMARY KEY(project_id), + CONSTRAINT fk_course FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE +); + +CREATE TABLE submissions ( + submission_id INT GENERATED ALWAYS AS IDENTITY, + uid VARCHAR(255) NOT NULL, + project_id INT NOT NULL, + grading INTEGER CHECK (grading >= 0 AND grading <= 20), + submission_time TIMESTAMP WITH TIME ZONE NOT NULL, + submission_path VARCHAR(50) NOT NULL, + submission_status BOOLEAN NOT NULL, + PRIMARY KEY(submission_id), + CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, + CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) +); diff --git a/backend/tests.yaml b/backend/tests.yaml index 43e401c9..6bb872ca 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -13,6 +13,8 @@ services: timeout: 3s retries: 3 start_period: 5s + volumes: + - ./db_construct.sql:/docker-entrypoint-initdb.d/init.sql test-runner: build: From 4290e6a2d9c8975e664ca56c187a7c367d052952 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:25:26 +0100 Subject: [PATCH 047/377] #15 - Test for post method and fixtures for populating the database --- .../endpoints/index/OpenAPI_Object.json | 26 +--- backend/project/endpoints/submissions.py | 37 +++-- backend/pylintrc | 1 + backend/tests/endpoints/conftest.py | 140 ++++++++++++++++-- backend/tests/endpoints/submissions_test.py | 116 +++++++++++---- 5 files changed, 242 insertions(+), 78 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 78d4fc80..4729c11b 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -127,10 +127,12 @@ "type": "object", "properties": { "uid": { - "type": "string" + "type": "string", + "required": true }, "project_id": { - "type": "int" + "type": "int", + "required": true }, "grading": { "type": "int", @@ -150,6 +152,9 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "submission": { "type": "string" } @@ -159,22 +164,7 @@ } }, "400": { - "description": "A 'bad data field' message", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "A 'not found' message", + "description": "An invalid data message", "content": { "application/json": { "schema": { diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 188e7def..ff504221 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -63,6 +63,7 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ + data = {} try: with db.session() as session: submission = m_submissions() @@ -70,22 +71,29 @@ def post(self) -> dict[str, any]: # User uid = request.form.get("uid") if (uid is None) or (session.get(m_users, uid) is None): - return {"message": f"User {uid} not found"}, 404 + if uid is None: + data["message"] = "The uid data field is required" + else: + data["message"] = f"Invalid user (uid={uid})" + return data, 400 submission.uid = uid # Project project_id = request.form.get("project_id") - if (project_id is None) or (session.get(m_projects, project_id)): - return {"message": f"Project {project_id} not found"}, 404 + if (project_id is None) or (session.get(m_projects, project_id) is None): + if project_id is None: + data["message"] = "The project_id data field is required" + else: + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 submission.project_id = project_id # Grading - if "grading" in request.form: - grading = request.form["grading"] - if grading < 0 or grading > 20: - return { - "message": "The submission must have a 'grading' in between 0-20" - }, 400 + grading = int(request.form.get("grading")) + if grading is not None: + if not 0 <= grading <= 20: + data["message"] = "Invalid grading (range=0-20)" + return data, 400 submission.grading = grading # Submission time @@ -100,12 +108,15 @@ def post(self) -> dict[str, any]: session.add(submission) session.commit() - return { - "submission": f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" - }, 201 + + data["message"] = "Successfully fetched the submissions" + data["submission"] = f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" + return data, 201 + except exc.SQLAlchemyError: session.rollback() - return {"message": "An error occurred while creating a new submission "}, 500 + data["message"] = "An error occurred while creating a new submission" + return data, 500 class Submission(Resource): """API endpoint for the submission""" diff --git a/backend/pylintrc b/backend/pylintrc index 86b66862..0770aea9 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -3,6 +3,7 @@ init-hook='import sys; sys.path.append(".")' [test-files:*_test.py] disable= + W0613, # Unused argument (pytest uses it) W0621, # Redefining name %r from outer scope (line %s) R0904, # Too many public methods (too many unit tests essentially) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index d61dd089..81dfc103 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,34 +1,144 @@ """ Configuration for pytest, Flask, and the test client.""" + +from datetime import datetime import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project.database import db, get_database_uri +from project.models.users import Users as m_users +from project.models.courses import Courses as m_courses +from project.models.course_relations import CourseAdmins as m_course_admins +from project.models.course_relations import CourseStudents as m_course_students +from project.models.projects import Projects as m_projects +from project.models.submissions import Submissions as m_submissions + +@pytest.fixture +def users(): + """Return a list of users to populate the database""" + return [ + m_users(uid="brinkmann", is_admin=True, is_teacher=True), + m_users(uid="laermans", is_admin=True, is_teacher=True), + m_users(uid="student01", is_admin=False, is_teacher=False), + m_users(uid="student02", is_admin=False, is_teacher=False) + ] + +@pytest.fixture +def courses(): + """Return a list of courses to populate the database""" + return [ + m_courses(course_id=1, name="AD3", teacher="brinkmann"), + m_courses(course_id=2, name="RAF", teacher="laermans"), + ] + +@pytest.fixture +def course_relations(): + """Returns a list of course relations to populate the database""" + return [ + m_course_admins(course_id=1, uid="brinkmann"), + m_course_students(course_id=1, uid="student01"), + m_course_students(course_id=1, uid="student02"), + m_course_admins(course_id=2, uid="laermans"), + m_course_students(course_id=2, uid="student02") + ] + +@pytest.fixture +def projects(): + """Return a list of projects to populate the database""" + return [ + m_projects( + project_id=1, + title="B+ Trees", + descriptions="Implement B+ trees", + assignment_file="assignement.pdf", + deadline=datetime(2024,3,15,13,0,0), + course_id=1, + visible_for_students=True, + archieved=False, + test_path="/tests", + script_name="script.sh", + regex_expressions=["*"] + ), + m_projects( + project_id=2, + title="Predicaten", + descriptions="Predicaten project", + assignment_file="assignment.pdf", + deadline=datetime(2023,3,15,13,0,0), + course_id=2, + visible_for_students=False, + archieved=True, + test_path="/tests", + script_name="script.sh", + regex_expressions=["*"] + ) + ] + +@pytest.fixture +def submissions(): + """Return a list of submissions to populate the database""" + return [ + m_submissions( + submission_id=1, + uid="student01", + project_id=1, + grading=16, + submission_time=datetime(2024,3,14,12,0,0), + submission_path="/submissions/1", + submission_status=True + ), + m_submissions( + submission_id=2, + uid="student02", + project_id=1, + submission_time=datetime(2024,3,14,23,59,59), + submission_path="/submissions/2", + submission_status=False + ), + m_submissions( + submission_id=3, + uid="student02", + project_id=2, + grading=15, + submission_time=datetime(2023,3,5,10,0,0), + submission_path="/submissions/3", + submission_status=True + ) + ] engine = create_engine(get_database_uri()) Session = sessionmaker(bind=engine) @pytest.fixture -def session(): +def session(users,courses,course_relations,projects,submissions): """Create a database session for the tests""" - # Create all tables + # Create all tables and get a session db.metadata.create_all(engine) - session = Session() - # Populate the database - session.commit() - - # Tests can now use a populated database - yield session + try: + # Populate the database + session.add_all(users) + session.commit() + session.add_all(courses) + session.commit() + session.add_all(course_relations) + session.commit() + session.add_all(projects) + session.commit() + session.add_all(submissions) + session.commit() - # Rollback - session.rollback() - session.close() + # Tests can now use a populated database + yield session + finally: + # Rollback + session.rollback() + session.close() - # Remove all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() + # Remove all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() @pytest.fixture def app(): diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5971a60e..e3b45b1b 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -1,56 +1,61 @@ """Test the submissions API endpoint""" from os import getenv +from flask.testing import FlaskClient +from sqlalchemy.orm import Session +from project.models.submissions import Submissions as m_submissions class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" ### GET SUBMISSIONS ### - def test_get_submissions_wrong_user(self, client): + def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing user""" response = client.get("/submissions?uid=unknown") data = response.json assert response.status_code == 400 assert data["message"] == "Invalid user (uid=unknown)" - def test_get_submissions_wrong_project(self, client): + def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") data = response.json assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" - def test_get_submissions_all(self, client): + def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 assert data["submissions"] == [ f"{getenv('HOSTNAME')}/submissions/1", - f"{getenv('HOSTNAME')}/submissions/2" + f"{getenv('HOSTNAME')}/submissions/2", + f"{getenv('HOSTNAME')}/submissions/3" ] - def test_get_submissions_user(self, client): + def test_get_submissions_user(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user""" - response = client.get("/submissions?uid=user4") + response = client.get("/submissions?uid=student01") data = response.json assert response.status_code == 200 assert data["submissions"] == [ f"{getenv('HOSTNAME')}/submissions/1" ] - def test_get_submissions_project(self, client): + def test_get_submissions_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific project""" response = client.get("/submissions?project_id=1") data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1" + f"{getenv('HOSTNAME')}/submissions/1", + f"{getenv('HOSTNAME')}/submissions/2" ] - def test_get_submissions_user_project(self, client): + def test_get_submissions_user_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user and project""" - response = client.get("/submissions?uid=user4&project_id=1") + response = client.get("/submissions?uid=student01&project_id=1") data = response.json assert response.status_code == 200 assert data["submissions"] == [ @@ -58,59 +63,106 @@ def test_get_submissions_user_project(self, client): ] ### POST SUBMISSIONS ### - def test_post_submissions_wrong_user(self, client, session): + def test_post_submissions_no_user(self, client: FlaskClient, session: Session): + """Test posting a submission without specifying a user""" + response = client.post("/submissions", data={ + "project_id": 1 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "The uid data field is required" + + def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing user""" + response = client.post("/submissions", data={ + "uid": "unknown", + "project_id": 1 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid user (uid=unknown)" - def test_post_submissions_wrong_project(self, client, session): + def test_post_submissions_no_project(self, client: FlaskClient, session: Session): + """Test posting a submission without specifying a project""" + response = client.post("/submissions", data={ + "uid": "student01" + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "The project_id data field is required" + + def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": -1 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=-1)" - def test_post_submissions_wrong_grading(self, client, session): + def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "grading": 80 + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (range=0-20)" - def test_post_submissions_wrong_form(self, client, session): - """Test posting a submission with a wrong data form""" - - def test_post_submissions_wrong_files(self, client, session): + def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): """Test posting a submission with no or wrong files""" - def test_post_submissions_database_issue(self, client, session): - """Test posting the submissions with a faulty database""" - - def test_post_submissions_correct(self, client, session): + def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "grading": 16 + }) + data = response.json + assert response.status_code == 201 + assert data["message"] == "Successfully fetched the submissions" + + submission = session.query(m_submissions).filter_by( + uid="student01", project_id=1, grading=16 + ).first() + assert submission is not None ### GET SUBMISSION ### - def test_get_submission_wrong_id(self, client, session): + def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - def test_get_submission_database_issue(self, client, session): + def test_get_submission_database_issue(self, client: FlaskClient, session: Session): """Test getting a submission with a faulty database""" - def test_get_submission_correct(self, client, session): + def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" ### PATCH SUBMISSION ### - def test_patch_submission_wrong_id(self, client, session): + def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - def test_patch_submission_wrong_grading(self, client, session): + def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" - def test_patch_submission_wrong_form(self, client, session): + def test_patch_submission_wrong_form(self, client: FlaskClient, session: Session): """Test patching a submisson with a wrong data form""" - def test_patch_submission_database_issue(self, client, session): + def test_patch_submission_database_issue(self, client: FlaskClient, session: Session): """Test patching a submission with a faulty database""" - def test_patch_submission_correct(self, client, session): + def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" ### DELETE SUBMISSION ### - def test_delete_submission_wrong_id(self, client, session): + def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - def test_delete_submission_database_issue(self, client, session): + def test_delete_submission_database_issue(self, client: FlaskClient, session: Session): """Test deleting a submission with a faulty database""" - def test_delete_submission_correct(self, client, session): + def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" From 3edfcafa9c3088b265afb6d82b5ec48b67f3f663 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 15:56:48 +0100 Subject: [PATCH 048/377] #15 - Tests for the get method --- .../endpoints/index/OpenAPI_Object.json | 45 +++++++++-------- backend/project/endpoints/submissions.py | 48 +++++++++++-------- backend/tests/endpoints/submissions_test.py | 19 ++++++-- 3 files changed, 69 insertions(+), 43 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 4729c11b..488be8c2 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -196,7 +196,7 @@ } } }, - "/submissions/{sid}": { + "/submissions/{submission_id}": { "get": { "summary": "Get the submission", "responses": { @@ -207,26 +207,31 @@ "schema": { "type": "object", "properties": { - "submission_id": { - "type": "int" - }, - "uid": { - "type": "string" - }, - "project_id": { - "type": "int" - }, - "grading": { - "type": "int" - }, - "submission_time": { - "type": "string" - }, - "submission_path": { + "message": { "type": "string" }, - "submission_status": { - "type": "int" + "submission": { + "submission_id": { + "type": "int" + }, + "uid": { + "type": "string" + }, + "project_id": { + "type": "int" + }, + "grading": { + "type": "int" + }, + "submission_time": { + "type": "string" + }, + "submission_path": { + "type": "string" + }, + "submission_status": { + "type": "int" + } } } } @@ -399,7 +404,7 @@ }, "parameters": [ { - "name": "sid", + "name": "submission_id", "in": "path", "description": "Submission ID", "required": true, diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index ff504221..e40192eb 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -121,24 +121,26 @@ def post(self) -> dict[str, any]: class Submission(Resource): """API endpoint for the submission""" - def get(self, sid: int) -> dict[str, any]: + def get(self, submission_id: int) -> dict[str, any]: """Get the submission given an submission ID Args: - sid (int): Submission ID + submission_id (int): Submission ID Returns: dict[str, any]: The submission """ + data = {} try: with db.session() as session: - # Get the submission - submission = session.get(m_submissions, sid) + submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {sid} not found"}, 404 + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 - return { + data["message"] = "Successfully fetched the submission" + data["submission"] = { "submission_id": submission.submission_id, "uid": submission.uid, "project_id": submission.project_id, @@ -147,14 +149,17 @@ def get(self, sid: int) -> dict[str, any]: "submission_path": submission.submission_path, "submission_status": submission.submission_status } + return data, 200 except exc.SQLAlchemyError: - return {"message": f"An error occurred while fetching submission {sid}"}, 500 + data["message"] = \ + f"An error occurred while fetching the submission (submission_id={submission_id})" + return data, 500 - def patch(self, sid:int) -> dict[str, any]: + def patch(self, submission_id:int) -> dict[str, any]: """Update some fields of a submission given a submission ID Args: - sid (int): Submission ID + submission_id (int): Submission ID Returns: dict[str, any]: A message @@ -163,9 +168,9 @@ def patch(self, sid:int) -> dict[str, any]: try: with db.session() as session: # Get the submission - submission = session.get(m_submissions, sid) + submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {sid} not found"}, 404 + return {"message": f"Submission {submission_id} not found"}, 404 # Update the grading field (its the only field that a teacher can update) if "grading" in request.form: @@ -178,16 +183,16 @@ def patch(self, sid:int) -> dict[str, any]: # Save the submission session.commit() - return {"message": f"Submission {sid} updated"} + return {"message": f"Submission {submission_id} updated"} except exc.SQLAlchemyError: session.rollback() - return {"message": f"An error occurred while patching submission {sid}"}, 500 + return {"message": f"An error occurred while patching submission {submission_id}"}, 500 - def delete(self, sid: int) -> dict[str, any]: + def delete(self, submission_id: int) -> dict[str, any]: """Delete a submission given an submission ID Args: - sid (int): Submission ID + submission_id (int): Submission ID Returns: dict[str, any]: A message @@ -196,17 +201,20 @@ def delete(self, sid: int) -> dict[str, any]: try: with db.session() as session: # Check if the submission exists - submission = session.get(m_submissions, sid) + submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {sid} not found"}, 404 + return {"message": f"Submission {submission_id} not found"}, 404 # Delete the submission session.delete(submission) session.commit() - return {"message": f"Submission {sid} deleted"} + return {"message": f"Submission {submission_id} deleted"} except exc.SQLAlchemyError: db.session.rollback() - return {"message": f"An error occurred while deleting submission {sid}"}, 500 + return {"message": f"An error occurred while deleting submission {submission_id}"}, 500 submissions_bp.add_url_rule("/submissions", view_func=Submissions.as_view("submissions")) -submissions_bp.add_url_rule("/submissions/", view_func=Submission.as_view("submission")) +submissions_bp.add_url_rule( + "/submissions/", + view_func=Submission.as_view("submission") +) diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index e3b45b1b..75769fb3 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -134,12 +134,25 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - - def test_get_submission_database_issue(self, client: FlaskClient, session: Session): - """Test getting a submission with a faulty database""" + response = client.get("/submissions/100") + data = response.json + assert response.status_code == 404 + assert data["message"] == "Submission (submission_id=100) not found" def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" + response = client.get("/submissions/1") + data = response.json + assert response.status_code == 200 + assert data["submission"] == { + "submission_id": 1, + "uid": "student01", + "project_id": 1, + "grading": 16, + "submission_time": "Thu, 14 Mar 2024 11:00:00 GMT", + "submission_path": "/submissions/1", + "submission_status": True + } ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): From cb4823cd27b9c36d7887f221c8f71a0d81adacd2 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:01:08 +0100 Subject: [PATCH 049/377] disabled pylint message --- backend/project/endpoints/users.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 4a2a09b3..a0662c47 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -3,8 +3,8 @@ from flask_restful import Resource, Api from sqlalchemy.exc import SQLAlchemyError -from project import db # pylint: disable=import-error ; there is no error -from project.models.users import Users as userModel # pylint: disable=import-error ; there is no error +from project import db +from project.models.users import Users as userModel users_bp = Blueprint("users", __name__) users_api = Api(users_bp) From b262dd2311bff90c63fcb0831140849b6f5dd45f Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:02:36 +0100 Subject: [PATCH 050/377] disabled str --- backend/project/endpoints/users.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index a0662c47..16e0723b 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -53,7 +53,7 @@ def post(self): except SQLAlchemyError as e: # every exception should result in a rollback db.session.rollback() - return {"Message": f"An error occurred while creating the user: {str(e)}"}, 500 + return {"Message": f"An error occurred while creating the user"}, 500 return {"Message": "User created successfully!"}, 201 @@ -97,7 +97,7 @@ def patch(self, user_id): except SQLAlchemyError as e: # every exception should result in a rollback db.session.rollback() - return {"Message": f"An error occurred while patching the user: {str(e)}"}, 500 + return {"Message": f"An error occurred while patching the user"}, 500 return {"Message": "User updated successfully!"} def delete(self, user_id): @@ -115,7 +115,7 @@ def delete(self, user_id): except SQLAlchemyError as e: # every exception should result in a rollback db.session.rollback() - return {"Message": f"An error occurred while deleting the user: {str(e)}"}, 500 + return {"Message": f"An error occurred while deleting the user"}, 500 return {"Message": "User deleted successfully!"} From 5c3dbc5d0dc66ade944a03bdc5280f04b6b04340 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:06:34 +0100 Subject: [PATCH 051/377] pylint --- backend/project/endpoints/users.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 16e0723b..6260f302 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -50,10 +50,10 @@ def post(self): db.session.add(new_user) db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"Message": f"An error occurred while creating the user"}, 500 + return {"Message": "An error occurred while creating the user"}, 500 return {"Message": "User created successfully!"}, 201 @@ -94,10 +94,10 @@ def patch(self, user_id): # Save the changes to the database db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"Message": f"An error occurred while patching the user"}, 500 + return {"Message": "An error occurred while patching the user"}, 500 return {"Message": "User updated successfully!"} def delete(self, user_id): @@ -112,10 +112,10 @@ def delete(self, user_id): db.session.delete(user) db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"Message": f"An error occurred while deleting the user"}, 500 + return {"Message": "An error occurred while deleting the user"}, 500 return {"Message": "User deleted successfully!"} From 637b5b6c4f2b4643a30e19c3f514e3ed281a4ae6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 16:12:49 +0100 Subject: [PATCH 052/377] #15 - Tests for patch method --- .../endpoints/index/OpenAPI_Object.json | 8 +++---- backend/project/endpoints/submissions.py | 24 +++++++++++-------- backend/tests/endpoints/submissions_test.py | 23 ++++++++++++------ 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 488be8c2..dfce23f6 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -86,7 +86,7 @@ } }, "400": { - "description": "An invalid data message", + "description": "An 'invalid data' message", "content": { "application/json": { "schema": { @@ -164,7 +164,7 @@ } }, "400": { - "description": "An invalid data message", + "description": "An 'invalid data' message", "content": { "application/json": { "schema": { @@ -271,7 +271,7 @@ } }, "patch": { - "summary": "Update the submission", + "summary": "Patch the submission", "requestBody": { "description": "The submission data", "content": { @@ -306,7 +306,7 @@ } }, "400": { - "description": "A 'bad data field' message", + "description": "An 'invalid data' message", "content": { "application/json": { "schema": { diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index e40192eb..46371833 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -92,7 +92,7 @@ def post(self) -> dict[str, any]: grading = int(request.form.get("grading")) if grading is not None: if not 0 <= grading <= 20: - data["message"] = "Invalid grading (range=0-20)" + data["message"] = "Invalid grading (grading=0-20)" return data, 400 submission.grading = grading @@ -165,28 +165,32 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ + data = {} try: with db.session() as session: # Get the submission submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {submission_id} not found"}, 404 + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 - # Update the grading field (its the only field that a teacher can update) + # Update the grading field if "grading" in request.form: - grading = request.form["grading"] - if grading < 0 or grading > 20: - return { - "message": "The submission must have a 'grading' in between 0-20" - }, 400 + grading = int(request.form["grading"]) + if not 0 <= grading <= 20: + data["message"] = "Invalid grading (grading=0-20)" + return data, 400 submission.grading = grading # Save the submission session.commit() - return {"message": f"Submission {submission_id} updated"} + data["message"] = f"Successfully patched submission (submission_id={submission_id})" + return data, 200 except exc.SQLAlchemyError: session.rollback() - return {"message": f"An error occurred while patching submission {submission_id}"}, 500 + data["message"] = \ + f"An error occurred while patching submission (submission_id={submission_id})" + return data, 500 def delete(self, submission_id: int) -> dict[str, any]: """Delete a submission given an submission ID diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 75769fb3..4365026f 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -110,7 +110,7 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid grading (range=0-20)" + assert data["message"] == "Invalid grading (grading=0-20)" def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): """Test posting a submission with no or wrong files""" @@ -157,18 +157,27 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" + response = client.patch("/submissions/100", data={"grading": 20}) + data = response.json + assert response.status_code == 404 + assert data["message"] == "Submission (submission_id=100) not found" def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" - - def test_patch_submission_wrong_form(self, client: FlaskClient, session: Session): - """Test patching a submisson with a wrong data form""" - - def test_patch_submission_database_issue(self, client: FlaskClient, session: Session): - """Test patching a submission with a faulty database""" + response = client.patch("/submissions/2", data={"grading": 100}) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (grading=0-20)" def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" + response = client.patch("/submissions/2", data={"grading": 20}) + data = response.json + assert response.status_code == 200 + assert data["message"] == "Successfully patched submission (submission_id=2)" + + submission = session.get(m_submissions, 2) + assert submission.grading == 20 ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): From 1cf7dacf4296f513047127146bb706a46c5c6de3 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:13:06 +0100 Subject: [PATCH 053/377] merged with dev --- backend/project/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 1923ab4d..c9d9d432 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,7 @@ from flask import Flask from .db_in import db from .endpoints.index.index import index_bp +from .endpoints.users import users_bp def create_app(): @@ -16,6 +17,7 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) + app.register_blueprint(users_bp) return app From 4025e5519ca741ec9c9705fd5d18145f2e64a0be Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:17:04 +0100 Subject: [PATCH 054/377] typo --- backend/tests/models/conftest.py | 10 +++++----- backend/tests/models/course_test.py | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index a3d44c66..161a5e6d 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -81,16 +81,16 @@ def course_students_relation(course, course_students): @pytest.fixture -def assistent(): - """An assistent for testing.""" - assist = Users(uid="assistent_sel2") +def assistant(): + """An assistant for testing.""" + assist = Users(uid="assistant_sel2") return assist @pytest.fixture() -def course_admin(course, assistent): +def course_admin(course, assistant): """A course admin for testing.""" - admin_relation = CourseAdmins(uid=assistent.uid, course_id=course.course_id) + admin_relation = CourseAdmins(uid=assistant.uid, course_id=course.course_id) return admin_relation diff --git a/backend/tests/models/course_test.py b/backend/tests/models/course_test.py index 20898db8..764d244a 100644 --- a/backend/tests/models/course_test.py +++ b/backend/tests/models/course_test.py @@ -54,7 +54,7 @@ def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arg course_teacher, course_students, course_students_relation, - assistent, + assistant, course_admin, ): """Tests if we get the expected results for @@ -83,16 +83,16 @@ def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arg student_uids = [s.uid for s in course_students] assert student_check == student_uids - db_session.add(assistent) + db_session.add(assistant) db_session.commit() course_admin.course_id = course.course_id db_session.add(course_admin) db_session.commit() assert ( - db_session.query(CourseAdmins) - .filter_by(course_id=course.course_id) - .first() - .uid - == assistent.uid + db_session.query(CourseAdmins) + .filter_by(course_id=course.course_id) + .first() + .uid + == assistant.uid ) From 3dff6baa19cf3cebc730dbdead1d86d994dc0166 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:19:57 +0100 Subject: [PATCH 055/377] pylint changes --- backend/pylintrc | 4 +++- backend/tests/endpoints/user_test.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/pylintrc b/backend/pylintrc index 00d05061..77376fe1 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -2,12 +2,14 @@ init-hook='import sys; sys.path.append(".")' [MESSAGES CONTROL] -disable=W0621 # Redefining name %r from outer scope (line %s) +disable=W0621, # Redefining name %r from outer scope (line %s) + W0613 # unused-argument [test-files:*_test.py] disable= W0621, # Redefining name %r from outer scope (line %s) + W0613 # unused-argument [modules:project/modules/*] disable= diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c5dee2d9..b505724a 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -68,7 +68,7 @@ def test_wrong_datatype_post(self, client,user_db_session): # pylint: disable=u }) assert response.status_code == 415 - def test_get_all_users(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_get_all_users(self, client,user_db_session): """Test getting all users.""" response = client.get("/users") assert response.status_code == 200 From 2583ce40f1503765a321b15288db62a1e14d9401 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:20:53 +0100 Subject: [PATCH 056/377] pylint changes --- backend/tests/endpoints/user_test.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index b505724a..9adb928d 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -32,25 +32,25 @@ def user_db_session(): yield session session.rollback() session.close() - for table in reversed(db.metadata.sorted_tables): # pylint: disable=duplicate-code + for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() class TestUserEndpoint: """Class to test user management endpoints.""" - def test_delete_user(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_delete_user(self, client,user_db_session): """Test deleting a user.""" # Delete the user response = client.delete("/users/del") assert response.status_code == 200 assert response.json == {"Message": "User deleted successfully!"} - def test_delete_not_present(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_delete_not_present(self, client,user_db_session): """Test deleting a user that does not exist.""" response = client.delete("/users/non") assert response.status_code == 404 - def test_wrong_form_post(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_wrong_form_post(self, client,user_db_session): """Test posting with a wrong form.""" response = client.post("/users", json={ 'uid': '12', @@ -59,7 +59,7 @@ def test_wrong_form_post(self, client,user_db_session): # pylint: disable=unuse }) assert response.status_code == 400 - def test_wrong_datatype_post(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_wrong_datatype_post(self, client,user_db_session): """Test posting with a wrong data type.""" response = client.post("/users", data={ 'uid': '12', @@ -75,7 +75,7 @@ def test_get_all_users(self, client,user_db_session): # Check that the response is a list (even if it's empty) assert isinstance(response.json, list) - def test_get_one_user(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_get_one_user(self, client,user_db_session): """Test getting a single user.""" response = client.get("users/u_get") assert response.status_code == 200 @@ -85,7 +85,7 @@ def test_get_one_user(self, client,user_db_session): # pylint: disable=unused-a 'is_admin': False } - def test_patch_user(self, client, user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_patch_user(self, client, user_db_session): """Test updating a user.""" response = client.patch("/users/pat", json={ 'is_teacher': False, @@ -94,7 +94,7 @@ def test_patch_user(self, client, user_db_session): # pylint: disable=unused-ar assert response.status_code == 200 assert response.json == {"Message": "User updated successfully!"} - def test_patch_non_existent(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_patch_non_existent(self, client,user_db_session): """Test updating a non-existent user.""" response = client.patch("/users/non", json={ 'is_teacher': False, @@ -102,7 +102,7 @@ def test_patch_non_existent(self, client,user_db_session): # pylint: disable=un }) assert response.status_code == 404 - def test_patch_non_json(self, client,user_db_session): # pylint: disable=unused-argument ; pytest uses it + def test_patch_non_json(self, client,user_db_session): """Test sending a non-JSON patch request.""" response = client.post("/users", data={ 'uid': '12', From 5b567f39b4cca32eaa52c17feb7e30ad35cc7774 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 2 Mar 2024 16:35:25 +0100 Subject: [PATCH 057/377] pylint changes --- backend/tests/endpoints/user_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 9adb928d..4d69801a 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -31,7 +31,7 @@ def user_db_session(): session.commit() yield session session.rollback() - session.close() + session.close() # pylint: disable=duplicate-code; for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() From 1ccc3a0d7cc07dd52506caa09e6c8173eb4b1997 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 16:57:05 +0100 Subject: [PATCH 058/377] #15 - Tests for delete method --- backend/project/endpoints/submissions.py | 14 +++++++++----- backend/tests/endpoints/submissions_test.py | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 46371833..389f1b22 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -184,7 +184,7 @@ def patch(self, submission_id:int) -> dict[str, any]: # Save the submission session.commit() - data["message"] = f"Successfully patched submission (submission_id={submission_id})" + data["message"] = f"Submission (submission_id={submission_id}) patched" return data, 200 except exc.SQLAlchemyError: session.rollback() @@ -202,20 +202,24 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ + data = {} try: with db.session() as session: - # Check if the submission exists submission = session.get(m_submissions, submission_id) if submission is None: - return {"message": f"Submission {submission_id} not found"}, 404 + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 # Delete the submission session.delete(submission) session.commit() - return {"message": f"Submission {submission_id} deleted"} + data["message"] = f"Submission (submission_id={submission_id}) deleted" + return data, 200 except exc.SQLAlchemyError: db.session.rollback() - return {"message": f"An error occurred while deleting submission {submission_id}"}, 500 + data["message"] = \ + f"An error occurred while deleting submission (submission_id={submission_id})" + return data, 500 submissions_bp.add_url_rule("/submissions", view_func=Submissions.as_view("submissions")) submissions_bp.add_url_rule( diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 4365026f..6141c9d2 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -174,7 +174,7 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): response = client.patch("/submissions/2", data={"grading": 20}) data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully patched submission (submission_id=2)" + assert data["message"] == "Submission (submission_id=2) patched" submission = session.get(m_submissions, 2) assert submission.grading == 20 @@ -182,9 +182,17 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - - def test_delete_submission_database_issue(self, client: FlaskClient, session: Session): - """Test deleting a submission with a faulty database""" + response = client.delete("submissions/100") + data = response.json + assert response.status_code == 404 + assert data["message"] == "Submission (submission_id=100) not found" def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" + response = client.delete("submissions/1") + data = response.json + assert response.status_code == 200 + assert data["message"] == "Submission (submission_id=1) deleted" + + submission = session.get(m_submissions, 1) + assert submission is None From 65d84b525c08ea7a912a55e825e97f6f8efd6684 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 18:17:01 +0100 Subject: [PATCH 059/377] #15 - Testing for the correct type, now 400: bad request instead of 500: internal error --- backend/project/endpoints/submissions.py | 36 ++++++++++++--------- backend/tests/endpoints/submissions_test.py | 35 ++++++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 389f1b22..dba483a2 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -39,11 +39,10 @@ def get(self) -> dict[str, any]: # Filter by project_id project_id = request.args.get("project_id") if project_id is not None: - if session.get(m_projects, project_id) is not None: - query = query.filter_by(project_id=project_id) - else: + if not project_id.isdigit() or session.get(m_projects, int(project_id)) is None: data["message"] = f"Invalid project (project_id={project_id})" return data, 400 + query = query.filter_by(project_id=int(project_id)) # Get the submissions data["message"] = "Successfully fetched the submissions" @@ -80,21 +79,21 @@ def post(self) -> dict[str, any]: # Project project_id = request.form.get("project_id") - if (project_id is None) or (session.get(m_projects, project_id) is None): - if project_id is None: - data["message"] = "The project_id data field is required" - else: - data["message"] = f"Invalid project (project_id={project_id})" + if project_id is None: + data["message"] = "The project_id data field is required" return data, 400 - submission.project_id = project_id + if not project_id.isdigit() or session.get(m_projects, int(project_id)) is None: + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 + submission.project_id = int(project_id) # Grading - grading = int(request.form.get("grading")) + grading = request.form.get("grading") if grading is not None: - if not 0 <= grading <= 20: + if not (grading.isdigit() and 0 <= int(grading) <= 20): data["message"] = "Invalid grading (grading=0-20)" return data, 400 - submission.grading = grading + submission.grading = int(grading) # Submission time submission.submission_time = datetime.now() @@ -150,6 +149,7 @@ def get(self, submission_id: int) -> dict[str, any]: "submission_status": submission.submission_status } return data, 200 + except exc.SQLAlchemyError: data["message"] = \ f"An error occurred while fetching the submission (submission_id={submission_id})" @@ -175,17 +175,19 @@ def patch(self, submission_id:int) -> dict[str, any]: return data, 404 # Update the grading field - if "grading" in request.form: - grading = int(request.form["grading"]) - if not 0 <= grading <= 20: + grading = request.form.get("grading") + if grading is not None: + if not (grading.isdigit() and 0 <= int(grading) <= 20): data["message"] = "Invalid grading (grading=0-20)" return data, 400 - submission.grading = grading + submission.grading = int(grading) # Save the submission session.commit() + data["message"] = f"Submission (submission_id={submission_id}) patched" return data, 200 + except exc.SQLAlchemyError: session.rollback() data["message"] = \ @@ -213,8 +215,10 @@ def delete(self, submission_id: int) -> dict[str, any]: # Delete the submission session.delete(submission) session.commit() + data["message"] = f"Submission (submission_id={submission_id}) deleted" return data, 200 + except exc.SQLAlchemyError: db.session.rollback() data["message"] = \ diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 6141c9d2..9a742b73 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -23,6 +23,13 @@ def test_get_submissions_wrong_project(self, client: FlaskClient, session: Sessi assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" + def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + """Test getting submissions for a non-existing project of the wrong type""" + response = client.get("/submissions?project_id=zero") + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=zero)" + def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") @@ -101,6 +108,16 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" + def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + """Test posting a submission for a non-existing project of the wrong type""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": "zero" + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid project (project_id=zero)" + def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" response = client.post("/submissions", data={ @@ -112,6 +129,17 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" + def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): + """Test posting a submission with a wrong grading type""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "grading": "zero" + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (grading=0-20)" + def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): """Test posting a submission with no or wrong files""" @@ -169,6 +197,13 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" + def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): + """Test patching a submission with a wrong grading type""" + response = client.patch("/submissions/2", data={"grading": "zero"}) + data = response.json + assert response.status_code == 400 + assert data["message"] == "Invalid grading (grading=0-20)" + def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" response = client.patch("/submissions/2", data={"grading": 20}) From b6074731fdabdd88c779418af73e894b33ca78ba Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 2 Mar 2024 19:44:33 +0100 Subject: [PATCH 060/377] #15 - Updating HOSTNAME to API_HOST and call getenv() once at the start of the file --- backend/project/database.py | 11 +++++------ backend/project/endpoints/submissions.py | 8 ++++++-- backend/tests/endpoints/submissions_test.py | 16 +++++++++------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/backend/project/database.py b/backend/project/database.py index 1c879be5..776d7008 100644 --- a/backend/project/database.py +++ b/backend/project/database.py @@ -15,11 +15,10 @@ def get_database_uri() -> str: """ load_dotenv() uri = URL.create( - drivername=getenv("DB_DRIVER"), - username=getenv("DB_USER"), - password=getenv("DB_PASSWORD"), - host=getenv("DB_HOST"), - port=int(getenv("DB_PORT")), - database=getenv("DB_NAME") + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_NAME") ) return uri diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index dba483a2..3deb82fa 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,6 +2,7 @@ from datetime import datetime from os import getenv +from dotenv import load_dotenv from flask import Blueprint, request from flask_restful import Resource from sqlalchemy import exc @@ -10,6 +11,9 @@ from project.models.projects import Projects as m_projects from project.models.users import Users as m_users +load_dotenv() +API_HOST = getenv("API_HOST") + submissions_bp = Blueprint("submissions", __name__) class Submissions(Resource): @@ -47,7 +51,7 @@ def get(self) -> dict[str, any]: # Get the submissions data["message"] = "Successfully fetched the submissions" data["submissions"] = [ - f"{getenv('HOSTNAME')}/submissions/{s.submission_id}" for s in query.all() + f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() ] return data, 200 @@ -109,7 +113,7 @@ def post(self) -> dict[str, any]: session.commit() data["message"] = "Successfully fetched the submissions" - data["submission"] = f"{getenv('HOSTNAME')}/submissions/{submission.submission_id}" + data["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" return data, 201 except exc.SQLAlchemyError: diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 9a742b73..5bed1211 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -5,6 +5,8 @@ from sqlalchemy.orm import Session from project.models.submissions import Submissions as m_submissions +API_HOST = getenv("API_HOST") + class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" @@ -36,9 +38,9 @@ def test_get_submissions_all(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1", - f"{getenv('HOSTNAME')}/submissions/2", - f"{getenv('HOSTNAME')}/submissions/3" + f"{API_HOST}/submissions/1", + f"{API_HOST}/submissions/2", + f"{API_HOST}/submissions/3" ] def test_get_submissions_user(self, client: FlaskClient, session: Session): @@ -47,7 +49,7 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1" + f"{API_HOST}/submissions/1" ] def test_get_submissions_project(self, client: FlaskClient, session: Session): @@ -56,8 +58,8 @@ def test_get_submissions_project(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1", - f"{getenv('HOSTNAME')}/submissions/2" + f"{API_HOST}/submissions/1", + f"{API_HOST}/submissions/2" ] def test_get_submissions_user_project(self, client: FlaskClient, session: Session): @@ -66,7 +68,7 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio data = response.json assert response.status_code == 200 assert data["submissions"] == [ - f"{getenv('HOSTNAME')}/submissions/1" + f"{API_HOST}/submissions/1" ] ### POST SUBMISSIONS ### From 2ada21d3fd6d010f4337c5dcc3289e5bb3d1b300 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 3 Mar 2024 11:31:18 +0100 Subject: [PATCH 061/377] #15 - Updating responses to a more unified way --- .../endpoints/index/OpenAPI_Object.json | 204 +++++++++++++++--- backend/project/endpoints/submissions.py | 53 +++-- backend/tests/endpoints/submissions_test.py | 78 +++++-- 3 files changed, 265 insertions(+), 70 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index dfce23f6..bf978c85 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -42,7 +42,7 @@ ] }, "paths": { - "/submissions/{uid}/{pid}": { + "/submissions": { "get": { "summary": "Get the submissions", "parameters": [ @@ -59,7 +59,7 @@ "in": "query", "description": "Project ID", "schema": { - "type": "int" + "type": "integer" } } ], @@ -71,13 +71,21 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, - "submissions": { - "type": "array", - "items": { - "type": "string" + "data": { + "type": "object", + "properties": { + "submissions": "array", + "items": { + "type": "string", + "format": "uri" + } } } } @@ -92,8 +100,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -107,8 +123,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -131,11 +155,11 @@ "required": true }, "project_id": { - "type": "int", + "type": "integer", "required": true }, "grading": { - "type": "int", + "type": "integer", "minimum": 0, "maximum": 20 } @@ -152,11 +176,21 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, - "submission": { - "type": "string" + "data": { + "type": "object", + "properties": { + "submission": { + "type": "string", + "format": "uri" + } + } } } } @@ -170,8 +204,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -185,8 +227,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -207,30 +257,46 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, - "submission": { - "submission_id": { - "type": "int" - }, - "uid": { - "type": "string" - }, - "project_id": { - "type": "int" - }, - "grading": { - "type": "int" - }, - "submission_time": { - "type": "string" - }, - "submission_path": { - "type": "string" - }, - "submission_status": { - "type": "int" + "data": { + "type": "object", + "properties": { + "submission": { + "type": "object", + "properties": { + "submission_id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer", + "nullable": true + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" + } + } + } } } } @@ -245,8 +311,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -260,8 +334,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -280,7 +362,7 @@ "type": "object", "properties": { "grading": { - "type": "int", + "type": "integer", "minimum": 0, "maximum": 20 } @@ -297,8 +379,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -312,8 +402,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -327,8 +425,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -342,8 +448,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -362,8 +476,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -377,8 +499,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -392,8 +522,16 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" + }, + "data": { + "type": "object", + "properties": {} } } } @@ -409,7 +547,7 @@ "description": "Submission ID", "required": true, "schema": { - "type": "int" + "type": "integer" } } ] diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 3deb82fa..937eff66 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -26,7 +26,11 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - data = {} + data = { + "url": f"{API_HOST}/submissions", + "message": "Successfully fetched the submissions", + "data": {} + } try: with db.session() as session: query = session.query(m_submissions) @@ -49,8 +53,7 @@ def get(self) -> dict[str, any]: query = query.filter_by(project_id=int(project_id)) # Get the submissions - data["message"] = "Successfully fetched the submissions" - data["submissions"] = [ + data["data"]["submissions"] = [ f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() ] return data, 200 @@ -66,7 +69,11 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ - data = {} + data = { + "url": f"{API_HOST}/submissions", + "message": "Successfully fetched the submissions", + "data": {} + } try: with db.session() as session: submission = m_submissions() @@ -112,8 +119,7 @@ def post(self) -> dict[str, any]: session.add(submission) session.commit() - data["message"] = "Successfully fetched the submissions" - data["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["data"]["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" return data, 201 except exc.SQLAlchemyError: @@ -134,7 +140,11 @@ def get(self, submission_id: int) -> dict[str, any]: dict[str, any]: The submission """ - data = {} + data = { + "url": f"{API_HOST}/submissions/{submission_id}", + "message": "Successfully fetched the submission", + "data": {} + } try: with db.session() as session: submission = session.get(m_submissions, submission_id) @@ -142,15 +152,14 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 - data["message"] = "Successfully fetched the submission" - data["submission"] = { - "submission_id": submission.submission_id, - "uid": submission.uid, - "project_id": submission.project_id, + data["data"]["submission"] = { + "id": submission.submission_id, + "user": f"{API_HOST}/users/{submission.uid}", + "project": f"{API_HOST}/projects/{submission.project_id}", "grading": submission.grading, - "submission_time": submission.submission_time, - "submission_path": submission.submission_path, - "submission_status": submission.submission_status + "time": submission.submission_time, + "path": submission.submission_path, + "status": submission.submission_status } return data, 200 @@ -169,7 +178,11 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": f"{API_HOST}/submissions/{submission_id}", + "message": f"Submission (submission_id={submission_id}) patched", + "data": {} + } try: with db.session() as session: # Get the submission @@ -189,7 +202,6 @@ def patch(self, submission_id:int) -> dict[str, any]: # Save the submission session.commit() - data["message"] = f"Submission (submission_id={submission_id}) patched" return data, 200 except exc.SQLAlchemyError: @@ -208,7 +220,11 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": f"{API_HOST}/submissions/{submission_id}", + "message": f"Submission (submission_id={submission_id}) deleted", + "data": {} + } try: with db.session() as session: submission = session.get(m_submissions, submission_id) @@ -220,7 +236,6 @@ def delete(self, submission_id: int) -> dict[str, any]: session.delete(submission) session.commit() - data["message"] = f"Submission (submission_id={submission_id}) deleted" return data, 200 except exc.SQLAlchemyError: diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5bed1211..869c17c4 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -16,28 +16,36 @@ def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session) response = client.get("/submissions?uid=unknown") data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" + assert data["data"] == {} def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" + assert data["data"] == {} def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project of the wrong type""" response = client.get("/submissions?project_id=zero") data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" + assert data["data"] == {} def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2", f"{API_HOST}/submissions/3" @@ -48,7 +56,9 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): response = client.get("/submissions?uid=student01") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1" ] @@ -57,7 +67,9 @@ def test_get_submissions_project(self, client: FlaskClient, session: Session): response = client.get("/submissions?project_id=1") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2" ] @@ -67,7 +79,9 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio response = client.get("/submissions?uid=student01&project_id=1") data = response.json assert response.status_code == 200 - assert data["submissions"] == [ + assert data["url"] == f"{API_HOST}/submissions" + assert data["message"] == "Successfully fetched the submissions" + assert data["data"]["submissions"] == [ f"{API_HOST}/submissions/1" ] @@ -79,7 +93,9 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The uid data field is required" + assert data["data"] == {} def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing user""" @@ -89,7 +105,9 @@ def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" + assert data["data"] == {} def test_post_submissions_no_project(self, client: FlaskClient, session: Session): """Test posting a submission without specifying a project""" @@ -98,7 +116,9 @@ def test_post_submissions_no_project(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The project_id data field is required" + assert data["data"] == {} def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project""" @@ -108,7 +128,9 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" + assert data["data"] == {} def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project of the wrong type""" @@ -118,7 +140,9 @@ def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" + assert data["data"] == {} def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" @@ -129,7 +153,9 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" + assert data["data"] == {} def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading type""" @@ -140,10 +166,9 @@ def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" - - def test_post_submissions_wrong_files(self, client: FlaskClient, session: Session): - """Test posting a submission with no or wrong files""" + assert data["data"] == {} def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" @@ -154,12 +179,13 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 201 + assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - submission = session.query(m_submissions).filter_by( - uid="student01", project_id=1, grading=16 - ).first() - assert submission is not None + submission_id = int(data["data"]["submission"].split("/")[-1]) + submission = session.get(m_submissions, submission_id) + assert submission.uid == "student01" and submission.project_id == 1 \ + and submission.grading == 16 ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -167,21 +193,25 @@ def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.get("/submissions/100") data = response.json assert response.status_code == 404 + assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" + assert data["data"] == {} def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" response = client.get("/submissions/1") data = response.json assert response.status_code == 200 - assert data["submission"] == { - "submission_id": 1, - "uid": "student01", - "project_id": 1, + assert data["url"] == f"{API_HOST}/submissions/1" + assert data["message"] == "Successfully fetched the submission" + assert data["data"]["submission"] == { + "id": 1, + "user": f"{API_HOST}/users/student01", + "project": f"{API_HOST}/projects/1", "grading": 16, - "submission_time": "Thu, 14 Mar 2024 11:00:00 GMT", - "submission_path": "/submissions/1", - "submission_status": True + "time": "Thu, 14 Mar 2024 11:00:00 GMT", + "path": "/submissions/1", + "status": True } ### PATCH SUBMISSION ### @@ -190,28 +220,36 @@ def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.patch("/submissions/100", data={"grading": 20}) data = response.json assert response.status_code == 404 + assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" + assert data["data"] == {} def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" response = client.patch("/submissions/2", data={"grading": 100}) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" + assert data["data"] == {} def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading type""" response = client.patch("/submissions/2", data={"grading": "zero"}) data = response.json assert response.status_code == 400 + assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" + assert data["data"] == {} def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" response = client.patch("/submissions/2", data={"grading": 20}) data = response.json assert response.status_code == 200 + assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Submission (submission_id=2) patched" + assert data["data"] == {} submission = session.get(m_submissions, 2) assert submission.grading == 20 @@ -222,14 +260,18 @@ def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session) response = client.delete("submissions/100") data = response.json assert response.status_code == 404 + assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" + assert data["data"] == {} def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" response = client.delete("submissions/1") data = response.json assert response.status_code == 200 + assert data["url"] == f"{API_HOST}/submissions/1" assert data["message"] == "Submission (submission_id=1) deleted" + assert data["data"] == {} submission = session.get(m_submissions, 1) assert submission is None From 8ca7d82905e2fc8d15a8e2cda1c09900a8f2976e Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sun, 3 Mar 2024 18:48:15 +0100 Subject: [PATCH 062/377] typo --- backend/project/models/users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/models/users.py b/backend/project/models/users.py index 8a27353a..331a5048 100644 --- a/backend/project/models/users.py +++ b/backend/project/models/users.py @@ -7,7 +7,7 @@ @dataclasses.dataclass class Users(db.Model): """This class defines the users table, - a user has an uid, + a user has a uid, is_teacher and is_admin booleans because a user can be either a student,admin or teacher""" From 9309c006927da842f5148d7ec8801b55b22c4d88 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Mon, 4 Mar 2024 09:08:46 +0100 Subject: [PATCH 063/377] Backend/feature/course endpoint (#33) * Basic db definition in flask * main.py * Defined official db model in SQLalchemy * db model definitions in sqlalchemy * flask_sqlalchemy in requirements * foutje * fixed db_uri * db initialization inside create_app * db_uri and code clean * import order fix * database uri added * .env added to ignore * Updated docs and .gitignore * A first succesfull test for user model * Doc cleanup and test function for courses and course_relations models added * Project and submission test added * added psycopg to dependencies * dockerized tests to host postgres server * created test script * created test directory to test models * waiting for postgres service to start before running test scripts and moved env variables * changed github action to run test script instead * constructing pytests for models * running test script with sudo * adding bash to run script * fixing pytest * pytests fixed, 1 warning left * warning fix * import in init.py change because of import db errors, first base for users endpoint (#14) * first full version of endpoint for courses (#14) * course endpoint tests * removed some code duplication * fixed most of the requested changes * removed /courses?uid route since it doesnt fit here * fixed tests and correct codes * undid changes not belonging in this branch * C:/Program Files/Git/courses get with filters and more testing * instead of returning ids return link to endpoint for detail of object, also updated tests, more REST! * absolute path instead of relative * fixed api_url in test * test api_url * fix * api_url var uppercase fix * final fix? * oopsie in conftest * forgot password * forgot load_dotenv bruh * linter fixes * linter * fixed requested changes, url and message in all responses, split add_or_delete function * first attempt at openapi object * patch for courses/course_id, data in response of patch and post * openapi object * linter * urls and fun * grammar fix * Args --------- Co-authored-by: warre Co-authored-by: Aron Buzogany Co-authored-by: abuzogan --- backend/project/__init__.py | 10 +- backend/project/__main__.py | 7 +- backend/project/db_in.py | 20 +- backend/project/endpoints/courses.py | 619 +++++++++ .../endpoints/index/OpenAPI_Object.json | 1118 ++++++++++++++++- backend/project/models/course_relations.py | 1 - backend/project/models/projects.py | 1 - backend/project/models/users.py | 1 - backend/tests.yaml | 1 + backend/tests/endpoints/conftest.py | 132 +- backend/tests/endpoints/courses_test.py | 225 ++++ backend/tests/models/conftest.py | 22 +- 12 files changed, 2114 insertions(+), 43 deletions(-) create mode 100644 backend/project/endpoints/courses.py create mode 100644 backend/tests/endpoints/courses_test.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 1923ab4d..b970f5e1 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,8 @@ from flask import Flask from .db_in import db from .endpoints.index.index import index_bp +from .endpoints.courses import courses_bp + def create_app(): @@ -16,19 +18,23 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) + app.register_blueprint(courses_bp) return app -def create_app_with_db(db_uri:str): + +def create_app_with_db(db_uri: str): """ - Initialize the database with the given uri + Initialize the database with the given uri and connect it to the app made with create_app. Parameters: db_uri (str): The URI of the database to initialize. Returns: Flask -- A Flask application instance """ + app = create_app() app.config["SQLALCHEMY_DATABASE_URI"] = db_uri db.init_app(app) + return app diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 9448b0eb..2f312c85 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,12 +1,11 @@ """Main entry point for the application.""" + from sys import path -from os import getenv -from dotenv import load_dotenv from project import create_app_with_db +from project.db_in import url path.append(".") if __name__ == "__main__": - load_dotenv() - app = create_app_with_db(getenv("DB_HOST")) + app = create_app_with_db(url) app.run(debug=True) diff --git a/backend/project/db_in.py b/backend/project/db_in.py index 9cbda056..ebcc02dd 100644 --- a/backend/project/db_in.py +++ b/backend/project/db_in.py @@ -1,5 +1,23 @@ """db initialization""" -from flask_sqlalchemy import SQLAlchemy +import os +from flask_sqlalchemy import SQLAlchemy +from dotenv import load_dotenv +from sqlalchemy import URL db = SQLAlchemy() + +load_dotenv() + +DATABSE_NAME = os.getenv("POSTGRES_DB") +DATABASE_USER = os.getenv("POSTGRES_USER") +DATABASE_PASSWORD = os.getenv("POSTGRES_PASSWORD") +DATABASE_HOST = os.getenv("POSTGRES_HOST") + +url = URL.create( + drivername="postgresql", + username=DATABASE_USER, + host=DATABASE_HOST, + database=DATABSE_NAME, + password=DATABASE_PASSWORD, +) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py new file mode 100644 index 00000000..c583d7da --- /dev/null +++ b/backend/project/endpoints/courses.py @@ -0,0 +1,619 @@ +"""Courses api point""" + +from os import getenv +from dotenv import load_dotenv +from flask import Blueprint, jsonify, request +from flask import abort +from flask_restful import Api, Resource +from sqlalchemy.exc import SQLAlchemyError +from project.models.course_relations import CourseAdmins, CourseStudents +from project.models.users import Users +from project.models.courses import Courses +from project.models.projects import Projects +from project import db + +courses_bp = Blueprint("courses", __name__) +courses_api = Api(courses_bp) + +load_dotenv() +API_URL = getenv("API_HOST") + + +def execute_query_abort_if_db_error(query, url, query_all=False): + """ + Execute the given SQLAlchemy query and handle any SQLAlchemyError that might occur. + If query_all == True, the query will be executed with the all() method, + otherwise with the first() method. + Args: + query (Query): The SQLAlchemy query to execute. + + Returns: + ResultProxy: The result of the query if successful, otherwise aborts with error 500. + """ + try: + if query_all: + result = query.all() + else: + result = query.first() + except SQLAlchemyError as e: + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + return result + + +def add_abort_if_error(to_add, url): + """ + Add a new object to the database + and handle any SQLAlchemyError that might occur. + + Args: + to_add (object): The object to add to the database. + """ + try: + db.session.add(to_add) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def delete_abort_if_error(to_delete, url): + """ + Deletes the given object from the database + and aborts the request with a 500 error if a SQLAlchemyError occurs. + + Args: + - to_delete: The object to be deleted from the database. + """ + try: + db.session.delete(to_delete) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def commit_abort_if_error(url): + """ + Commit the current session and handle any SQLAlchemyError that might occur. + """ + try: + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): + """ + Check if the current user is authorized to appoint new admins to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + HTTPException: If the current user is not authorized or + if the UID of the person to be made an admin is missing in the request body. + """ + url = API_URL + "/courses/" + str(course_id) + "/admins" + abort_if_uid_is_none(teacher, url) + + course = get_course_abort_if_not_found(course_id) + + if teacher != course.teacher: + response = json_message("Only the teacher of a course can appoint new admins") + response["url"] = url + abort(403, description=response) + + if not assistant: + response = json_message( + "uid of person to make admin is required in the request body" + ) + response["url"] = url + abort(400, description=response) + + +def abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids +): + """ + Check the request to assign new students to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + 403: If the user is not authorized to assign new students to the course. + 400: If the request body does not contain the required 'students' field. + """ + url = API_URL + "/courses/" + str(course_id) + "/students" + get_course_abort_if_not_found(course_id) + abort_if_no_user_found_for_uid(uid, url) + query = CourseAdmins.query.filter_by(uid=uid, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, url) + if not admin_relation: + message = "Not authorized to assign new students to course with id " + str( + course_id + ) + response = json_message(message) + response["url"] = url + abort(403, description=response) + + if not student_uids: + message = """To assign new students to a course, + you should have a students field with a list of uids in the request body""" + response = json_message(message) + response["url"] = url + abort(400, description=response) + + +def abort_if_uid_is_none(uid, url): + """ + Check whether the uid is None if so + abort with error 400 + """ + if uid is None: + response = json_message("There should be a uid in the request query") + response["url"] = url + abort(400, description=response) + + +def abort_if_no_user_found_for_uid(uid, url): + """ + Check if a user exists based on the provided uid. + + Args: + uid (int): The unique identifier of the user. + + Raises: + NotFound: If the user with the given uid is not found. + """ + query = Users.query.filter_by(uid=uid) + user = execute_query_abort_if_db_error(query, url) + + if not user: + response = json_message("User with uid " + uid + " was not found") + response["url"] = url + abort(404, description=response) + return user + + +def get_admin_relation(uid, course_id): + """ + Retrieve the CourseAdmins object for the given uid and course. + + Args: + uid (int): The user ID. + course_id (int): The course ID. + + Returns: + CourseAdmins: The CourseAdmins object if the user is an admin, otherwise None. + """ + return execute_query_abort_if_db_error( + CourseAdmins.query.filter_by(uid=uid, course_id=course_id), + url=API_URL + "/courses/" + str(course_id) + "/admins", + ) + + +def json_message(message): + """ + Create a json message with the given message. + + Args: + message (str): The message to include in the json. + + Returns: + dict: The message in a json format. + """ + return {"message": message} + + +def get_course_abort_if_not_found(course_id): + """ + Get a course by its ID. + + Args: + course_id (int): The course ID. + + Returns: + Courses: The course with the given ID. + """ + query = Courses.query.filter_by(course_id=course_id) + course = execute_query_abort_if_db_error(query, API_URL + "/courses") + + if not course: + response = json_message("Course not found") + response["url"] = API_URL + "/courses" + abort(404, description=response) + + return course + + +class CoursesForUser(Resource): + """Api endpoint for the /courses link""" + + def get(self): + """ " + Get function for /courses this will be the main endpoint + to get all courses and filter by given query parameter like /courses?parameter=... + parameters can be either one of the following: teacher,ufora_id,name. + """ + query = Courses.query + if "teacher" in request.args: + query = query.filter_by(course_id=request.args.get("teacher")) + if "ufora_id" in request.args: + query = query.filter_by(ufora_id=request.args.get("ufora_id")) + if "name" in request.args: + query = query.filter_by(name=request.args.get("name")) + results = execute_query_abort_if_db_error( + query, url=API_URL + "/courses", query_all=True + ) + detail_urls = [ + f"{API_URL}/courses/{str(course.course_id)}" for course in results + ] + message = "Succesfully retrieved all courses with given parameters" + response = json_message(message) + response["data"] = detail_urls + response["url"] = API_URL + "/courses" + return response + + def post(self): + """ + This function will create a new course + if the body of the post contains a name and uid is an admin or teacher + """ + abort_url = API_URL + "/courses" + uid = request.args.get("uid") + abort_if_uid_is_none(uid, abort_url) + + user = abort_if_no_user_found_for_uid(uid, abort_url) + + if not user.is_teacher: + message = ( + "Only teachers or admins can create new courses, you are unauthorized" + ) + return json_message(message), 403 + + data = request.get_json() + + if "name" not in data: + message = "Missing 'name' in the request body" + return json_message(message), 400 + + name = data["name"] + new_course = Courses(name=name, teacher=uid) + if "ufora_id" in data: + new_course.ufora_id = data["ufora_id"] + + add_abort_if_error(new_course, abort_url) + commit_abort_if_error(abort_url) + + admin_course = CourseAdmins(uid=uid, course_id=new_course.course_id) + add_abort_if_error(admin_course, abort_url) + commit_abort_if_error(abort_url) + + message = (f"Course with name: {name} and" + f"course_id:{new_course.course_id} was succesfully created") + response = json_message(message) + data = { + "course_id": API_URL + "/courses/" + str(new_course.course_id), + "name": new_course.name, + "teacher": API_URL + "/users/" + new_course.teacher, + "ufora_id": new_course.ufora_id if new_course.ufora_id else "None", + } + response["data"] = data + response["url"] = API_URL + "/courses/" + str(new_course.course_id) + return response, 201 + + +class CoursesByCourseId(Resource): + """Api endpoint for the /courses/course_id link""" + + def get(self, course_id): + """ + This get function will return all the related projects of the course + in the following form: + { + course: course with course_id + projects: [ + list of all projects that have course_id + where projects are jsons containing the title, deadline and project_id + ] + } + """ + abort_url = API_URL + "/courses" + uid = request.args.get("uid") + abort_if_uid_is_none(uid, abort_url) + admin = get_admin_relation(uid, course_id) + query = CourseStudents.query.filter_by(uid=uid, course_id=course_id) + student = execute_query_abort_if_db_error(query, abort_url) + + if not (admin or student): + message = "User is not an admin, nor a student of this course" + return json_message(message), 404 + + course = get_course_abort_if_not_found(course_id) + query = Projects.query.filter_by(course_id=course_id) + abort_url = API_URL + "/courses/" + str(course_id) + # course does exist so url should be to the id + project_uids = [ + API_URL + "/projects/" + project.project_id + for project in execute_query_abort_if_db_error( + query, abort_url, query_all=True + ) + ] + query = CourseAdmins.query.filter_by(course_id=course_id) + admin_uids = [ + API_URL + "/users/" + admin.uid + for admin in execute_query_abort_if_db_error( + query, abort_url, query_all=True + ) + ] + query = CourseStudents.query.filter_by(course_id=course_id) + student_uids = [ + API_URL + "/users/" + student.uid + for student in execute_query_abort_if_db_error( + query, abort_url, query_all=True + ) + ] + + data = { + "ufora_id": course.ufora_id, + "teacher": API_URL + "/users/" + course.teacher, + "admins": admin_uids, + "students": student_uids, + "projects": project_uids, + } + response = json_message( + "Succesfully retrieved course with course_id: " + str(course_id) + ) + response["data"] = data + response["url"] = API_URL + "/courses/" + str(course_id) + return response + + def delete(self, course_id): + """ + This function will delete the course with course_id + """ + abort_url = API_URL + "/courses/" + uid = request.args.get("uid") + abort_if_uid_is_none(uid, abort_url) + + admin = get_admin_relation(uid, course_id) + + if not admin: + message = "You are not an admin of this course and so you cannot delete it" + return json_message(message), 403 + + course = get_course_abort_if_not_found(course_id) + abort_url = API_URL + "/courses/" + str(course_id) + # course does exist so url should be to the id + delete_abort_if_error(course, abort_url) + commit_abort_if_error(abort_url) + + response = { + "message": "Succesfully deleted course with course_id: " + str(course_id), + "url": API_URL + "/courses", + } + return response + + def patch(self, course_id): + """ + This function will update the course with course_id + """ + abort_url = API_URL + "/courses/" + uid = request.args.get("uid") + abort_if_uid_is_none(uid, abort_url) + + admin = get_admin_relation(uid, course_id) + + if not admin: + message = "You are not an admin of this course and so you cannot update it" + return json_message(message), 403 + + data = request.get_json() + course = get_course_abort_if_not_found(course_id) + abort_url = API_URL + "/courses/" + str(course_id) + if "name" in data: + course.name = data["name"] + if "teacher" in data: + course.teacher = data["teacher"] + if "ufora_id" in data: + course.ufora_id = data["ufora_id"] + + commit_abort_if_error(abort_url) + response = json_message( + "Succesfully updated course with course_id: " + str(course_id) + ) + response["url"] = API_URL + "/courses/" + str(course_id) + data = { + "course_id": API_URL + "/courses/" + str(course.course_id), + "name": course.name, + "teacher": API_URL + "/users/" + course.teacher, + "ufora_id": course.ufora_id if course.ufora_id else "None", + } + response["data"] = data + return response, 200 + + +class CoursesForAdmins(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/admins url, only the teacher of a course can do this + """ + + def get(self, course_id): + """ + This function will return all the admins of a course + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + get_course_abort_if_not_found(course_id) + + query = CourseAdmins.query.filter_by(course_id=course_id) + admin_uids = [ + API_URL + "/users/" + a.uid + for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) + ] + response = json_message( + "Succesfully retrieved all admins of course " + str(course_id) + ) + response["data"] = admin_uids + response["url"] = abort_url # not actually aborting here tho heheh + return jsonify(admin_uids) + + def post(self, course_id): + """ + Api endpoint for adding new admins to a course, can only be done by the teacher + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = Users.query.filter_by(uid=assistant) + new_admin = execute_query_abort_if_db_error(query, abort_url) + if not new_admin: + message = ( + "User to make admin was not found, please request with a valid uid" + ) + return json_message(message), 404 + + admin_relation = CourseAdmins(uid=assistant, course_id=course_id) + add_abort_if_error(admin_relation, abort_url) + commit_abort_if_error(abort_url) + response = json_message( + f"Admin assistant added to course {course_id}" + ) + response["url"] = abort_url + data = { + "course_id": API_URL + "/courses/" + str(course_id), + "uid": API_URL + "/users/" + assistant, + } + response["data"] = data + return response, 201 + + def delete(self, course_id): + """ + Api endpoint for removing admins of a course, can only be done by the teacher + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = CourseAdmins.query.filter_by(uid=assistant, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, abort_url) + if not admin_relation: + message = "Course with given admin not found" + return json_message(message), 404 + + delete_abort_if_error(admin_relation, abort_url) + commit_abort_if_error(abort_url) + + message = ( + f"Admin {assistant}" + f" was succesfully removed from course {course_id}" + ) + response = json_message(message) + response["url"] = abort_url + return response, 204 + + +class CoursesToAddStudents(Resource): + """ + Class that will respond to the /courses/course_id/students link + teachers should be able to assign and remove students from courses, + and everyone should be able to list all students assigned to a course + """ + + def get(self, course_id): + """ + Get function at /courses/course_id/students + to get all the users assigned to a course + everyone can get this data so no need to have uid query in the link + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + get_course_abort_if_not_found(course_id) + + query = CourseStudents.query.filter_by(course_id=course_id) + student_uids = [ + API_URL + "/users/" + s.uid + for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) + ] + response = json_message( + "Succesfully retrieved all students of course " + str(course_id) + ) + response["data"] = student_uids + response["url"] = abort_url + return response + + def post(self, course_id): + """ + Allows admins of a course to assign new students by posting to: + /courses/course_id/students with a list of uid in the request body under key "students" + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudents.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + db.session.rollback() + message = ( + "Student with uid " + uid + " is already assigned to the course" + ) + return json_message(message), 400 + add_abort_if_error(CourseStudents(uid=uid, course_id=course_id), abort_url) + commit_abort_if_error(abort_url) + response = json_message("Users were succesfully added to the course") + response["url"] = abort_url + data = {"students": [API_URL + "/users/" + uid for uid in student_uids]} + response["data"] = data + return response, 201 + + def delete(self, course_id): + """ + This function allows admins of a course to remove students by sending a delete request to + /courses/course_id/students with inside the request body + a field "students" = [list of uids to unassign] + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudents.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + delete_abort_if_error(student_relation, abort_url) + commit_abort_if_error(abort_url) + + response = json_message("Users were succesfully removed from the course") + response["url"] = API_URL + "/courses/" + str(course_id) + "/students" + return response + + +courses_api.add_resource(CoursesForUser, "/courses") + +courses_api.add_resource(CoursesByCourseId, "/courses/") + +courses_api.add_resource(CoursesForAdmins, "/courses//admins") + +courses_api.add_resource(CoursesToAddStudents, "/courses//students") diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 7243ff59..b26e89b0 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -41,5 +41,1121 @@ } ] }, - "paths": [] + "paths": { + + "/courses": { + "get": { + "description": "Get a list of all courses.", + "responses": { + "200": { + "description": "Successfully retrieved all courses with given parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "name", + "in": "query", + "description": "Name of the course", + "schema": { + "type": "string" + } + }, + { + "name": "ufora_id", + "in": "query", + "description": "Ufora ID of the course", + "schema": { + "type": "string" + } + }, + { + "name": "teacher", + "in": "query", + "description": "Teacher of the course", + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "description": "Create a new course.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the course" + }, + "teacher": { + "type": "string", + "description": "Teacher of the course" + } + }, + "required": ["name", "teacher"] + } + } + } + }, + "parameters":[ + { + "name":"uid", + "in":"query", + "description":"uid of the user sending the request", + "schema":{ + "type":"string" + } + } + ], + "responses":{ + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "201": { + "description": "Course with name: {name} and course_id: {course_id} was successfully created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "course_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "teacher": { + "type": "string" + }, + "ufora_id": { + "type": "string" + } + } + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "The user trying to create a course was unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "The user trying to create a course was not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }}, + "/courses/{course_id}" : { + "get": { + "description": "Get a course by its ID.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Course found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "ufora_id": { + "type": "string" + }, + "teacher": { + "type": "string" + }, + "admins": { + "type": "array", + "items": { + "type": "string" + } + }, + "students": { + "type": "array", + "items": { + "type": "string" + } + }, + "projects": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "description": "Delete a course by its ID.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Course deleted.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Successfully deleted course with course_id: {course_id}" + }, + "url": { + "type": "string", + "example": "{API_URL}/courses" + } + } + } + } + } + }, + "403" : { + "description": "The user trying to delete the course was unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "patch":{ + "description": "Update the course with given ID.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the course", + "required" : false + }, + "teacher": { + "type": "string", + "description": "Teacher of the course", + "required" : false + }, + "ufora_id": { + "type": "string", + "description": "Ufora ID of the course", + "required" : false + } + } + } + } + } + }, + "responses" : { + "200": { + "description": "Course updated.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "url": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "course_id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "teacher": { + "type": "string" + }, + "ufora_id": { + "type": "string" + } + } + } + } + } + } + } + }, + "403" : { + "description": "The user trying to update the course was unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/courses/{course_id}/students": { + "get": { + "description": "Get a list of all students in a course.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved all students of course.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "post":{ + "description": "Assign students to a course.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "students", + "in": "body", + "description": "list of uids of the students to be assigned to the course", + "required": true, + "schema": { + "type": "array" + } + }, + { + "name":"uid", + "in":"query", + "description":"uid of the user sending the request", + "schema":{ + "type":"string" + } + } + ], + "responses": { + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "201": { + "description": "Students assigned to course.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Users were succesfully added to the course" + }, + "url": { + "type": "string", + "example": "http://api.example.com/courses/123/students" + }, + "data": { + "type": "object", + "properties": { + "students": { + "type": "array", + "items": { + "type": "string", + "example": "http://api.example.com/users/123" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "The user trying to assign students to the course was unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete":{ + "description": "Remove students from a course.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "students", + "in": "body", + "description": "list of uids of the students to be removed from the course", + "required": true, + "schema": { + "type": "array" + } + }, + { + "name":"uid", + "in":"query", + "description":"uid of the user sending the request", + "schema":{ + "type":"string" + } + } + ], + "responses":{ + "204": { + "description": "Students removed from course.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Users were succesfully removed from the course" + }, + "url": { + "type": "string", + "example": "API_URL + '/courses/' + str(course_id) + '/students'" + } + } + } + } + } + }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "403":{ + "description": "The user trying to remove students from the course was unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500":{ + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404":{ + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/courses/{course_id}/admins": { + "get": { + "description": "Get a list of all admins in a course.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved all admins of course.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "post":{ + "description": "Assign admins to a course.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "admin_uid", + "in": "body", + "description": "uid of the admin to be assigned to the course", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name":"uid", + "in":"query", + "description":"uid of the user sending the request", + "schema":{ + "type":"string" + } + } + ], + "responses": { + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "201": { + "description": "Users were successfully added to the course.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Users were successfully added to the course." + }, + "url": { + "type": "string", + "example": "http://api.example.com/courses/123/students" + }, + "data": { + "type": "object", + "properties": { + "students": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["http://api.example.com/users/1", "http://api.example.com/users/2"] + } + } + } + } + } + } + } + }, + "403": { + "description": "The user trying to assign admins to the course was unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404": { + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete":{ + "description": "Remove an admin from a course.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "admin_uid", + "in": "body", + "description": "uid of the admin to be removed from the course", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name":"uid", + "in":"query", + "description":"uid of the user sending the request", + "schema":{ + "type":"string" + } + } + ], + "responses":{ + "204": { + "description": "User was successfully removed from the course admins.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "User was successfully removed from the course admins." + }, + "url": { + "type": "string", + "example": "API_URL + '/courses/' + str(course_id) + '/students'" + } + } + } + } + } + }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "403":{ + "description": "The user trying to remove the admin from the course was unauthorized.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "500":{ + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, + "404":{ + "description": "Course not found.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + } + + } } diff --git a/backend/project/models/course_relations.py b/backend/project/models/course_relations.py index c53807b6..be2a06a8 100644 --- a/backend/project/models/course_relations.py +++ b/backend/project/models/course_relations.py @@ -1,5 +1,4 @@ """Models for relation between users and courses""" - from sqlalchemy import Integer, Column, ForeignKey, PrimaryKeyConstraint, String from project import db diff --git a/backend/project/models/projects.py b/backend/project/models/projects.py index 0dd37911..13d8ced3 100644 --- a/backend/project/models/projects.py +++ b/backend/project/models/projects.py @@ -1,5 +1,4 @@ """Model for projects""" - from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from project import db diff --git a/backend/project/models/users.py b/backend/project/models/users.py index c3ea45c0..03748175 100644 --- a/backend/project/models/users.py +++ b/backend/project/models/users.py @@ -1,5 +1,4 @@ """Model for users""" - from sqlalchemy import Boolean, Column, String from project import db diff --git a/backend/tests.yaml b/backend/tests.yaml index 6bb872ca..7b799a2e 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -28,6 +28,7 @@ services: POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database + API_HOST: http://api_is_here volumes: - .:/app command: ["pytest"] diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 010ef293..f7618fc8 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,22 +1,126 @@ -""" Configuration for pytest, Flask, and the test client.""" +"""Configuration for pytest, Flask, and the test client.""" + +import os import pytest -from project import create_app +from project.models.courses import Courses +from project.models.users import Users +from project.models.course_relations import CourseStudents,CourseAdmins +from project import create_app_with_db, db +from project.db_in import url + + +@pytest.fixture +def api_url(): + """Get the API URL from the environment.""" + return os.getenv("API_HOST") + @pytest.fixture def app(): - """A fixture that creates and configure a new app instance for each test. - Returns: - Flask -- A Flask application instance - """ - app = create_app() - yield app + """Get the app""" + app = create_app_with_db(url) + return app + @pytest.fixture def client(app): - """A fixture that creates a test client for the app. - Arguments: - app {Flask} -- A Flask application instance - Returns: - Flask -- A Flask test client instance + """Returns client for testing requests to the app.""" + with app.test_client() as client: + with app.app_context(): + yield client + + +@pytest.fixture +def db_session(app): + """Create a new database session for a test. + After the test, all changes are rolled back and the session is closed.""" + app = create_app_with_db(url) + with app.app_context(): + for table in reversed(db.metadata.sorted_tables): + db.session.execute(table.delete()) + db.session.commit() + + yield db.session + db.session.close() + +@pytest.fixture +def courses_get_db(db_with_course): + """Database equipped for the get tests""" + for x in range(3,10): + course = Courses(teacher="Bart", name="Sel" + str(x)) + db_with_course.add(course) + db_with_course.commit() + db_with_course.add(CourseAdmins(course_id=course.course_id,uid="Bart")) + db_with_course.commit() + course = db_with_course.query(Courses).filter_by(name="Sel2").first() + db_with_course.add(CourseAdmins(course_id=course.course_id,uid="Rien")) + db_with_course.add_all( + [CourseStudents(course_id=course.course_id, uid="student_sel2_" + str(i)) + for i in range(3)]) + db_with_course.commit() + return db_with_course + +@pytest.fixture +def db_with_course(courses_init_db): + """A database with a course.""" + courses_init_db.add(Courses(name="Sel2", teacher="Bart")) + courses_init_db.commit() + course = courses_init_db.query(Courses).filter_by(name="Sel2").first() + courses_init_db.add(CourseAdmins(course_id=course.course_id,uid="Bart")) + courses_init_db.commit() + return courses_init_db + +@pytest.fixture +def course_data(): + """A valid course for testing.""" + return {"name": "Sel2", "teacher": "Bart"} + +@pytest.fixture +def invalid_course(): + """An invalid course for testing.""" + return {"invalid": "error"} + +@pytest.fixture +def courses_init_db(db_session, course_students, course_teacher, course_assistent): + """ + What do we need to test the courses api standalone: + A teacher that can make a new course + and some students + and an assistent """ - return app.test_client() + db_session.add_all(course_students) + db_session.add(course_teacher) + db_session.add(course_assistent) + db_session.commit() + return db_session + + +@pytest.fixture +def course_students(): + """A list of 5 students for testing.""" + students = [ + Users(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) + for i in range(5) + ] + return students + + +@pytest.fixture +def course_teacher(): + """A user that's a teacher for testing""" + sel2_teacher = Users(uid="Bart", is_teacher=True, is_admin=False) + return sel2_teacher + + +@pytest.fixture +def course_assistent(): + """A user that's a teacher for testing""" + sel2_assistent = Users(uid="Rien", is_teacher=True, is_admin=False) + return sel2_assistent + + +@pytest.fixture +def course(course_teacher): + """A course for testing, with the course teacher as the teacher.""" + sel2 = Courses(name="Sel2", teacher=course_teacher.uid) + return sel2 diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py new file mode 100644 index 00000000..cf42dc5d --- /dev/null +++ b/backend/tests/endpoints/courses_test.py @@ -0,0 +1,225 @@ +"""Here we will test all the courses endpoint related functionality""" + +from project.models.course_relations import CourseStudents, CourseAdmins + + +from project.models.courses import Courses + + +class TestCoursesEndpoint: + """Class for testing the courses endpoint""" + + def test_post_courses(self, courses_init_db, client, course_data, invalid_course): + """ + Test posting a course to the /courses endpoint + """ + response = client.post("/courses?uid=Bart", json=course_data) # valid user + + for x in range(3, 10): + course = {"name": "Sel" + str(x), "teacher": "Bart"} + response = client.post("/courses?uid=Bart", json=course) # valid user + assert response.status_code == 201 + assert response.status_code == 201 # succes post = 201 + + course = courses_init_db.query(Courses).filter_by(name="Sel2").first() + assert course is not None + assert course.teacher == "Bart" + + response = client.post( + "/courses?uid=Jef", json=course_data + ) # non existent user + assert response.status_code == 404 + + response = client.post( + "/courses?uid=student_sel2_0", json=course_data + ) # existent user but no rights + assert response.status_code == 403 + + response = client.post("/courses", json=course_data) # bad link, no uid passed + assert response.status_code == 400 + + response = client.post( + "/courses?uid=Bart", json=invalid_course + ) # invalid course + assert response.status_code == 400 + + def test_post_courses_course_id_students_and_admins(self, db_with_course, client): + """ + Test posting to courses/course_id/students and admins + """ + course = db_with_course.query(Courses).filter_by(name="Sel2").first() + # Posting to /courses/course_id/students and admins test + valid_students = { + "students": ["student_sel2_0", "student_sel2_1", "student_sel2_2"] + } + bad_students = {"error": ["student_sel2_0", "student_sel2_1"]} + sel2_students_link = "/courses/" + str(course.course_id) + + response = client.post( + sel2_students_link + "/students?uid=student_sel2_0", + json=valid_students, # unauthorized user + ) + assert response.status_code == 403 + + assert course.teacher == "Bart" + response = client.post( + sel2_students_link + "/students?uid=Bart", + json=valid_students, # authorized user + ) + + assert response.status_code == 201 # succes post = 201 + users = [ + s.uid + for s in CourseStudents.query.filter_by(course_id=course.course_id).all() + ] + assert users == valid_students["students"] + + response = client.post( + sel2_students_link + "/students?uid=Bart", + json=valid_students, # already added students + ) + assert response.status_code == 400 + + response = client.post( + sel2_students_link + "/students?uid=Bart", + json=bad_students, # bad request + ) + assert response.status_code == 400 + + sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" + + response = client.post( + sel2_admins_link + "?uid=student_sel2_0", # unauthorized user + json={"admin_uid": "Rien"}, + ) + assert response.status_code == 403 + course_admins = [ + s.uid + for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + ] + assert course_admins == ["Bart"] + + response = client.post( + sel2_admins_link + "?uid=Bart", # authorized user + json={"admin_uid": "Rin"}, # non existent user + ) + assert response.status_code == 404 + + response = client.post( + sel2_admins_link + "?uid=Bart", # authorized user + json={"admin_uid": "Rien"}, # existing user + ) + admins = [ + s.uid + for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + ] + assert admins == ["Bart", "Rien"] + + def test_get_courses(self, courses_get_db, client, api_url): + """ + Test all the getters for the courses endpoint + """ + course = courses_get_db.query(Courses).filter_by(name="Sel2").first() + sel2_students_link = "/courses/" + str(course.course_id) + + for x in range(3, 10): + response = client.get(f"/courses?name=Sel{str(x)}") + assert response.status_code == 200 + link = response.json["url"] + assert len(link) == len(f"{api_url}/courses") + response = client.get(link + "?uid=Bart") + assert response.status_code == 200 + + sel2_students = [ + f"{api_url}/users/" + s.uid + for s in CourseStudents.query.filter_by(course_id=course.course_id).all() + ] + + response = client.get(sel2_students_link + "/students?uid=Bart") + assert response.status_code == 200 + response_json = response.json # the students ids are in the json without a key + assert response_json["data"] == sel2_students + + def test_course_delete(self, courses_get_db, client): + """Test all course endpoint related delete functionality""" + + course = courses_get_db.query(Courses).filter_by(name="Sel2").first() + sel2_students_link = "/courses/" + str(course.course_id) + response = client.delete( + sel2_students_link + "/students?uid=student_sel2_0", + json={"students": ["student_sel2_0"]}, + ) + assert response.status_code == 403 + + response = client.delete( + sel2_students_link + "/students?uid=Bart", + json={"students": ["student_sel2_0"]}, + ) + assert response.status_code == 200 + + students = [ + s.uid + for s in CourseStudents.query.filter_by(course_id=course.course_id).all() + ] + assert students == ["student_sel2_1", "student_sel2_2"] + + response = client.delete( + sel2_students_link + "/students?uid=Bart", json={"error": ["invalid"]} + ) + assert response.status_code == 400 + + response = client.delete( + sel2_students_link + "/admins?uid=Bart", json={"admin_uid": "error"} + ) + assert response.status_code == 404 + + assert ( + sel2_students_link + "/admins?uid=Bart" + == "/courses/" + str(course.course_id) + "/admins?uid=Bart" + ) + response = client.delete( + sel2_students_link + "/admins?uid=Bart", json={"admin_ud": "Rien"} + ) + assert response.status_code == 400 + + response = client.delete( + sel2_students_link + "/admins?uid=student_sel2_0", + json={"admin_uid": "Rien"}, + ) + assert response.status_code == 403 + + admins = [ + s.uid + for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + ] + assert admins == ["Bart", "Rien"] + response = client.delete( + sel2_students_link + "/admins?uid=Bart", json={"admin_uid": "Rien"} + ) + assert response.status_code == 204 + + admins = [ + s.uid + for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + ] + assert admins == ["Bart"] + + course = Courses.query.filter_by(name="Sel2").first() + assert course.teacher == "Bart" + response = client.delete( + "/courses/" + str(course.course_id) + "?uid=" + course.teacher + ) + assert response.status_code == 200 + + course = courses_get_db.query(Courses).filter_by(name="Sel2").first() + assert course is None + + def test_course_patch(self, db_with_course, client): + """ + Test the patching of a course + """ + body = {"name": "AD2"} + course = db_with_course.query(Courses).filter_by(name="Sel2").first() + response = client.patch(f"/courses/{course.course_id}?uid=Bart", json=body) + assert response.status_code == 200 + assert course.name == "AD2" diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 150d433e..a1bce4a5 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -2,33 +2,16 @@ Configuration for the models tests. Contains all the fixtures needed for multiple models tests. """ -import os from datetime import datetime from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from sqlalchemy.engine.url import URL -from dotenv import load_dotenv import pytest from project import db from project.models.courses import Courses from project.models.course_relations import CourseAdmins, CourseStudents from project.models.projects import Projects from project.models.users import Users - -load_dotenv() - -DATABSE_NAME = os.getenv('POSTGRES_DB') -DATABASE_USER = os.getenv('POSTGRES_USER') -DATABASE_PASSWORD = os.getenv('POSTGRES_PASSWORD') -DATABASE_HOST = os.getenv('POSTGRES_HOST') - -url = URL.create( - drivername="postgresql", - username=DATABASE_USER, - host=DATABASE_HOST, - database=DATABSE_NAME, - password=DATABASE_PASSWORD -) +from project.db_in import url engine = create_engine(url) Session = sessionmaker(bind=engine) @@ -39,6 +22,9 @@ def db_session(): After the test, all changes are rolled back and the session is closed.""" db.metadata.create_all(engine) session = Session() + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() yield session session.rollback() session.close() From b957cc507f10920e32fa54d5a25760a3250d66fd Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Mon, 4 Mar 2024 19:20:21 +0100 Subject: [PATCH 064/377] model classes are now singular (#53) --- backend/project/endpoints/courses.py | 74 +++++++++---------- .../endpoints/index/OpenAPI_Object.json | 8 +- backend/project/models/course_relations.py | 4 +- backend/project/models/courses.py | 4 +- backend/project/models/projects.py | 2 +- backend/project/models/submissions.py | 2 +- backend/project/models/users.py | 2 +- backend/tests/endpoints/conftest.py | 30 ++++---- backend/tests/endpoints/courses_test.py | 34 ++++----- backend/tests/models/conftest.py | 26 +++---- backend/tests/models/course_test.py | 22 +++--- .../models/projects_and_submissions_test.py | 16 ++-- backend/tests/models/users_test.py | 8 +- 13 files changed, 116 insertions(+), 116 deletions(-) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index c583d7da..1903e9e3 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -1,4 +1,4 @@ -"""Courses api point""" +"""Course api point""" from os import getenv from dotenv import load_dotenv @@ -6,10 +6,10 @@ from flask import abort from flask_restful import Api, Resource from sqlalchemy.exc import SQLAlchemyError -from project.models.course_relations import CourseAdmins, CourseStudents -from project.models.users import Users -from project.models.courses import Courses -from project.models.projects import Projects +from project.models.course_relations import CourseAdmin, CourseStudent +from project.models.users import User +from project.models.courses import Course +from project.models.projects import Project from project import db courses_bp = Blueprint("courses", __name__) @@ -134,7 +134,7 @@ def abort_if_none_uid_student_uids_or_non_existant_course_id( url = API_URL + "/courses/" + str(course_id) + "/students" get_course_abort_if_not_found(course_id) abort_if_no_user_found_for_uid(uid, url) - query = CourseAdmins.query.filter_by(uid=uid, course_id=course_id) + query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) admin_relation = execute_query_abort_if_db_error(query, url) if not admin_relation: message = "Not authorized to assign new students to course with id " + str( @@ -173,7 +173,7 @@ def abort_if_no_user_found_for_uid(uid, url): Raises: NotFound: If the user with the given uid is not found. """ - query = Users.query.filter_by(uid=uid) + query = User.query.filter_by(uid=uid) user = execute_query_abort_if_db_error(query, url) if not user: @@ -185,17 +185,17 @@ def abort_if_no_user_found_for_uid(uid, url): def get_admin_relation(uid, course_id): """ - Retrieve the CourseAdmins object for the given uid and course. + Retrieve the CourseAdmin object for the given uid and course. Args: uid (int): The user ID. course_id (int): The course ID. Returns: - CourseAdmins: The CourseAdmins object if the user is an admin, otherwise None. + CourseAdmin: The CourseAdmin object if the user is an admin, otherwise None. """ return execute_query_abort_if_db_error( - CourseAdmins.query.filter_by(uid=uid, course_id=course_id), + CourseAdmin.query.filter_by(uid=uid, course_id=course_id), url=API_URL + "/courses/" + str(course_id) + "/admins", ) @@ -221,9 +221,9 @@ def get_course_abort_if_not_found(course_id): course_id (int): The course ID. Returns: - Courses: The course with the given ID. + Course: The course with the given ID. """ - query = Courses.query.filter_by(course_id=course_id) + query = Course.query.filter_by(course_id=course_id) course = execute_query_abort_if_db_error(query, API_URL + "/courses") if not course: @@ -234,7 +234,7 @@ def get_course_abort_if_not_found(course_id): return course -class CoursesForUser(Resource): +class CourseForUser(Resource): """Api endpoint for the /courses link""" def get(self): @@ -243,7 +243,7 @@ def get(self): to get all courses and filter by given query parameter like /courses?parameter=... parameters can be either one of the following: teacher,ufora_id,name. """ - query = Courses.query + query = Course.query if "teacher" in request.args: query = query.filter_by(course_id=request.args.get("teacher")) if "ufora_id" in request.args: @@ -286,14 +286,14 @@ def post(self): return json_message(message), 400 name = data["name"] - new_course = Courses(name=name, teacher=uid) + new_course = Course(name=name, teacher=uid) if "ufora_id" in data: new_course.ufora_id = data["ufora_id"] add_abort_if_error(new_course, abort_url) commit_abort_if_error(abort_url) - admin_course = CourseAdmins(uid=uid, course_id=new_course.course_id) + admin_course = CourseAdmin(uid=uid, course_id=new_course.course_id) add_abort_if_error(admin_course, abort_url) commit_abort_if_error(abort_url) @@ -311,7 +311,7 @@ def post(self): return response, 201 -class CoursesByCourseId(Resource): +class CourseByCourseId(Resource): """Api endpoint for the /courses/course_id link""" def get(self, course_id): @@ -330,7 +330,7 @@ def get(self, course_id): uid = request.args.get("uid") abort_if_uid_is_none(uid, abort_url) admin = get_admin_relation(uid, course_id) - query = CourseStudents.query.filter_by(uid=uid, course_id=course_id) + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) student = execute_query_abort_if_db_error(query, abort_url) if not (admin or student): @@ -338,7 +338,7 @@ def get(self, course_id): return json_message(message), 404 course = get_course_abort_if_not_found(course_id) - query = Projects.query.filter_by(course_id=course_id) + query = Project.query.filter_by(course_id=course_id) abort_url = API_URL + "/courses/" + str(course_id) # course does exist so url should be to the id project_uids = [ @@ -347,14 +347,14 @@ def get(self, course_id): query, abort_url, query_all=True ) ] - query = CourseAdmins.query.filter_by(course_id=course_id) + query = CourseAdmin.query.filter_by(course_id=course_id) admin_uids = [ API_URL + "/users/" + admin.uid for admin in execute_query_abort_if_db_error( query, abort_url, query_all=True ) ] - query = CourseStudents.query.filter_by(course_id=course_id) + query = CourseStudent.query.filter_by(course_id=course_id) student_uids = [ API_URL + "/users/" + student.uid for student in execute_query_abort_if_db_error( @@ -441,7 +441,7 @@ def patch(self, course_id): return response, 200 -class CoursesForAdmins(Resource): +class CourseForAdmins(Resource): """ This class will handle post and delete queries to the /courses/course_id/admins url, only the teacher of a course can do this @@ -454,7 +454,7 @@ def get(self, course_id): abort_url = API_URL + "/courses/" + str(course_id) + "/admins" get_course_abort_if_not_found(course_id) - query = CourseAdmins.query.filter_by(course_id=course_id) + query = CourseAdmin.query.filter_by(course_id=course_id) admin_uids = [ API_URL + "/users/" + a.uid for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) @@ -476,7 +476,7 @@ def post(self, course_id): assistant = data.get("admin_uid") abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - query = Users.query.filter_by(uid=assistant) + query = User.query.filter_by(uid=assistant) new_admin = execute_query_abort_if_db_error(query, abort_url) if not new_admin: message = ( @@ -484,7 +484,7 @@ def post(self, course_id): ) return json_message(message), 404 - admin_relation = CourseAdmins(uid=assistant, course_id=course_id) + admin_relation = CourseAdmin(uid=assistant, course_id=course_id) add_abort_if_error(admin_relation, abort_url) commit_abort_if_error(abort_url) response = json_message( @@ -508,7 +508,7 @@ def delete(self, course_id): assistant = data.get("admin_uid") abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - query = CourseAdmins.query.filter_by(uid=assistant, course_id=course_id) + query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) admin_relation = execute_query_abort_if_db_error(query, abort_url) if not admin_relation: message = "Course with given admin not found" @@ -526,7 +526,7 @@ def delete(self, course_id): return response, 204 -class CoursesToAddStudents(Resource): +class CourseToAddStudents(Resource): """ Class that will respond to the /courses/course_id/students link teachers should be able to assign and remove students from courses, @@ -542,7 +542,7 @@ def get(self, course_id): abort_url = API_URL + "/courses/" + str(course_id) + "/students" get_course_abort_if_not_found(course_id) - query = CourseStudents.query.filter_by(course_id=course_id) + query = CourseStudent.query.filter_by(course_id=course_id) student_uids = [ API_URL + "/users/" + s.uid for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) @@ -568,7 +568,7 @@ def post(self, course_id): ) for uid in student_uids: - query = CourseStudents.query.filter_by(uid=uid, course_id=course_id) + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) student_relation = execute_query_abort_if_db_error(query, abort_url) if student_relation: db.session.rollback() @@ -576,9 +576,9 @@ def post(self, course_id): "Student with uid " + uid + " is already assigned to the course" ) return json_message(message), 400 - add_abort_if_error(CourseStudents(uid=uid, course_id=course_id), abort_url) + add_abort_if_error(CourseStudent(uid=uid, course_id=course_id), abort_url) commit_abort_if_error(abort_url) - response = json_message("Users were succesfully added to the course") + response = json_message("User were succesfully added to the course") response["url"] = abort_url data = {"students": [API_URL + "/users/" + uid for uid in student_uids]} response["data"] = data @@ -599,21 +599,21 @@ def delete(self, course_id): ) for uid in student_uids: - query = CourseStudents.query.filter_by(uid=uid, course_id=course_id) + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) student_relation = execute_query_abort_if_db_error(query, abort_url) if student_relation: delete_abort_if_error(student_relation, abort_url) commit_abort_if_error(abort_url) - response = json_message("Users were succesfully removed from the course") + response = json_message("User were succesfully removed from the course") response["url"] = API_URL + "/courses/" + str(course_id) + "/students" return response -courses_api.add_resource(CoursesForUser, "/courses") +courses_api.add_resource(CourseForUser, "/courses") -courses_api.add_resource(CoursesByCourseId, "/courses/") +courses_api.add_resource(CourseByCourseId, "/courses/") -courses_api.add_resource(CoursesForAdmins, "/courses//admins") +courses_api.add_resource(CourseForAdmins, "/courses//admins") -courses_api.add_resource(CoursesToAddStudents, "/courses//students") +courses_api.add_resource(CourseToAddStudents, "/courses//students") diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index b26e89b0..ec8fb29e 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -665,7 +665,7 @@ "properties": { "message": { "type": "string", - "example": "Users were succesfully added to the course" + "example": "User were succesfully added to the course" }, "url": { "type": "string", @@ -775,7 +775,7 @@ "properties": { "message": { "type": "string", - "example": "Users were succesfully removed from the course" + "example": "User were succesfully removed from the course" }, "url": { "type": "string", @@ -964,7 +964,7 @@ } }, "201": { - "description": "Users were successfully added to the course.", + "description": "User were successfully added to the course.", "content": { "application/json": { "schema": { @@ -972,7 +972,7 @@ "properties": { "message": { "type": "string", - "example": "Users were successfully added to the course." + "example": "User were successfully added to the course." }, "url": { "type": "string", diff --git a/backend/project/models/course_relations.py b/backend/project/models/course_relations.py index be2a06a8..9ee45c08 100644 --- a/backend/project/models/course_relations.py +++ b/backend/project/models/course_relations.py @@ -16,12 +16,12 @@ class BaseCourseRelation(db.Model): PrimaryKeyConstraint("course_id", "uid"), ) -class CourseAdmins(BaseCourseRelation): +class CourseAdmin(BaseCourseRelation): """Admin to course relation model""" __tablename__ = "course_admins" -class CourseStudents(BaseCourseRelation): +class CourseStudent(BaseCourseRelation): """Student to course relation model""" __tablename__ = "course_students" diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index 2b208b4c..dc778706 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,9 +1,9 @@ -"""The Courses model""" +"""The Course model""" from sqlalchemy import Integer, Column, ForeignKey, String from project import db -class Courses(db.Model): +class Course(db.Model): """This class described the courses table, a course has an id, name, optional ufora id and the teacher that created it""" diff --git a/backend/project/models/projects.py b/backend/project/models/projects.py index 13d8ced3..b31066d3 100644 --- a/backend/project/models/projects.py +++ b/backend/project/models/projects.py @@ -2,7 +2,7 @@ from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from project import db -class Projects(db.Model): +class Project(db.Model): """This class describes the projects table, a projects has an id, a title, a description, an optional assignment file that can contain more explanation of the projects, diff --git a/backend/project/models/submissions.py b/backend/project/models/submissions.py index 97e8762c..9b70f8f4 100644 --- a/backend/project/models/submissions.py +++ b/backend/project/models/submissions.py @@ -3,7 +3,7 @@ from sqlalchemy import Column,String,ForeignKey,Integer,CheckConstraint,DateTime,Boolean from project import db -class Submissions(db.Model): +class Submission(db.Model): """This class describes the submissions table, submissions can be made to a project, a submission has and id, a uid from the user that uploaded it, diff --git a/backend/project/models/users.py b/backend/project/models/users.py index 03748175..d1b35a20 100644 --- a/backend/project/models/users.py +++ b/backend/project/models/users.py @@ -3,7 +3,7 @@ from project import db -class Users(db.Model): +class User(db.Model): """This class defines the users table, a user has an uid, is_teacher and is_admin booleans because a user diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index f7618fc8..4e787ad9 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -2,9 +2,9 @@ import os import pytest -from project.models.courses import Courses -from project.models.users import Users -from project.models.course_relations import CourseStudents,CourseAdmins +from project.models.courses import Course +from project.models.users import User +from project.models.course_relations import CourseStudent,CourseAdmin from project import create_app_with_db, db from project.db_in import url @@ -47,15 +47,15 @@ def db_session(app): def courses_get_db(db_with_course): """Database equipped for the get tests""" for x in range(3,10): - course = Courses(teacher="Bart", name="Sel" + str(x)) + course = Course(teacher="Bart", name="Sel" + str(x)) db_with_course.add(course) db_with_course.commit() - db_with_course.add(CourseAdmins(course_id=course.course_id,uid="Bart")) + db_with_course.add(CourseAdmin(course_id=course.course_id,uid="Bart")) db_with_course.commit() - course = db_with_course.query(Courses).filter_by(name="Sel2").first() - db_with_course.add(CourseAdmins(course_id=course.course_id,uid="Rien")) + course = db_with_course.query(Course).filter_by(name="Sel2").first() + db_with_course.add(CourseAdmin(course_id=course.course_id,uid="Rien")) db_with_course.add_all( - [CourseStudents(course_id=course.course_id, uid="student_sel2_" + str(i)) + [CourseStudent(course_id=course.course_id, uid="student_sel2_" + str(i)) for i in range(3)]) db_with_course.commit() return db_with_course @@ -63,10 +63,10 @@ def courses_get_db(db_with_course): @pytest.fixture def db_with_course(courses_init_db): """A database with a course.""" - courses_init_db.add(Courses(name="Sel2", teacher="Bart")) + courses_init_db.add(Course(name="Sel2", teacher="Bart")) courses_init_db.commit() - course = courses_init_db.query(Courses).filter_by(name="Sel2").first() - courses_init_db.add(CourseAdmins(course_id=course.course_id,uid="Bart")) + course = courses_init_db.query(Course).filter_by(name="Sel2").first() + courses_init_db.add(CourseAdmin(course_id=course.course_id,uid="Bart")) courses_init_db.commit() return courses_init_db @@ -99,7 +99,7 @@ def courses_init_db(db_session, course_students, course_teacher, course_assisten def course_students(): """A list of 5 students for testing.""" students = [ - Users(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) + User(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) for i in range(5) ] return students @@ -108,19 +108,19 @@ def course_students(): @pytest.fixture def course_teacher(): """A user that's a teacher for testing""" - sel2_teacher = Users(uid="Bart", is_teacher=True, is_admin=False) + sel2_teacher = User(uid="Bart", is_teacher=True, is_admin=False) return sel2_teacher @pytest.fixture def course_assistent(): """A user that's a teacher for testing""" - sel2_assistent = Users(uid="Rien", is_teacher=True, is_admin=False) + sel2_assistent = User(uid="Rien", is_teacher=True, is_admin=False) return sel2_assistent @pytest.fixture def course(course_teacher): """A course for testing, with the course teacher as the teacher.""" - sel2 = Courses(name="Sel2", teacher=course_teacher.uid) + sel2 = Course(name="Sel2", teacher=course_teacher.uid) return sel2 diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index cf42dc5d..4df98cd5 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -1,12 +1,12 @@ """Here we will test all the courses endpoint related functionality""" -from project.models.course_relations import CourseStudents, CourseAdmins +from project.models.course_relations import CourseStudent, CourseAdmin -from project.models.courses import Courses +from project.models.courses import Course -class TestCoursesEndpoint: +class TestCourseEndpoint: """Class for testing the courses endpoint""" def test_post_courses(self, courses_init_db, client, course_data, invalid_course): @@ -21,7 +21,7 @@ def test_post_courses(self, courses_init_db, client, course_data, invalid_course assert response.status_code == 201 assert response.status_code == 201 # succes post = 201 - course = courses_init_db.query(Courses).filter_by(name="Sel2").first() + course = courses_init_db.query(Course).filter_by(name="Sel2").first() assert course is not None assert course.teacher == "Bart" @@ -47,7 +47,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client """ Test posting to courses/course_id/students and admins """ - course = db_with_course.query(Courses).filter_by(name="Sel2").first() + course = db_with_course.query(Course).filter_by(name="Sel2").first() # Posting to /courses/course_id/students and admins test valid_students = { "students": ["student_sel2_0", "student_sel2_1", "student_sel2_2"] @@ -70,7 +70,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client assert response.status_code == 201 # succes post = 201 users = [ s.uid - for s in CourseStudents.query.filter_by(course_id=course.course_id).all() + for s in CourseStudent.query.filter_by(course_id=course.course_id).all() ] assert users == valid_students["students"] @@ -95,7 +95,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client assert response.status_code == 403 course_admins = [ s.uid - for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() ] assert course_admins == ["Bart"] @@ -111,7 +111,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client ) admins = [ s.uid - for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() ] assert admins == ["Bart", "Rien"] @@ -119,7 +119,7 @@ def test_get_courses(self, courses_get_db, client, api_url): """ Test all the getters for the courses endpoint """ - course = courses_get_db.query(Courses).filter_by(name="Sel2").first() + course = courses_get_db.query(Course).filter_by(name="Sel2").first() sel2_students_link = "/courses/" + str(course.course_id) for x in range(3, 10): @@ -132,7 +132,7 @@ def test_get_courses(self, courses_get_db, client, api_url): sel2_students = [ f"{api_url}/users/" + s.uid - for s in CourseStudents.query.filter_by(course_id=course.course_id).all() + for s in CourseStudent.query.filter_by(course_id=course.course_id).all() ] response = client.get(sel2_students_link + "/students?uid=Bart") @@ -143,7 +143,7 @@ def test_get_courses(self, courses_get_db, client, api_url): def test_course_delete(self, courses_get_db, client): """Test all course endpoint related delete functionality""" - course = courses_get_db.query(Courses).filter_by(name="Sel2").first() + course = courses_get_db.query(Course).filter_by(name="Sel2").first() sel2_students_link = "/courses/" + str(course.course_id) response = client.delete( sel2_students_link + "/students?uid=student_sel2_0", @@ -159,7 +159,7 @@ def test_course_delete(self, courses_get_db, client): students = [ s.uid - for s in CourseStudents.query.filter_by(course_id=course.course_id).all() + for s in CourseStudent.query.filter_by(course_id=course.course_id).all() ] assert students == ["student_sel2_1", "student_sel2_2"] @@ -190,7 +190,7 @@ def test_course_delete(self, courses_get_db, client): admins = [ s.uid - for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() ] assert admins == ["Bart", "Rien"] response = client.delete( @@ -200,18 +200,18 @@ def test_course_delete(self, courses_get_db, client): admins = [ s.uid - for s in CourseAdmins.query.filter_by(course_id=course.course_id).all() + for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() ] assert admins == ["Bart"] - course = Courses.query.filter_by(name="Sel2").first() + course = Course.query.filter_by(name="Sel2").first() assert course.teacher == "Bart" response = client.delete( "/courses/" + str(course.course_id) + "?uid=" + course.teacher ) assert response.status_code == 200 - course = courses_get_db.query(Courses).filter_by(name="Sel2").first() + course = courses_get_db.query(Course).filter_by(name="Sel2").first() assert course is None def test_course_patch(self, db_with_course, client): @@ -219,7 +219,7 @@ def test_course_patch(self, db_with_course, client): Test the patching of a course """ body = {"name": "AD2"} - course = db_with_course.query(Courses).filter_by(name="Sel2").first() + course = db_with_course.query(Course).filter_by(name="Sel2").first() response = client.patch(f"/courses/{course.course_id}?uid=Bart", json=body) assert response.status_code == 200 assert course.name == "AD2" diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index a1bce4a5..7bd5e2a0 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -7,10 +7,10 @@ from sqlalchemy.orm import sessionmaker import pytest from project import db -from project.models.courses import Courses -from project.models.course_relations import CourseAdmins, CourseStudents -from project.models.projects import Projects -from project.models.users import Users +from project.models.courses import Course +from project.models.course_relations import CourseAdmin, CourseStudent +from project.models.projects import Project +from project.models.users import User from project.db_in import url engine = create_engine(url) @@ -36,32 +36,32 @@ def db_session(): @pytest.fixture def valid_user(): """A valid user for testing""" - user = Users(uid="student", is_teacher=False, is_admin=False) + user = User(uid="student", is_teacher=False, is_admin=False) return user @pytest.fixture def teachers(): """A list of 10 teachers for testing""" - users = [Users(uid=str(i), is_teacher=True, is_admin=False) for i in range(10)] + users = [User(uid=str(i), is_teacher=True, is_admin=False) for i in range(10)] return users @pytest.fixture def course_teacher(): """A user that's a teacher for for testing""" - sel2_teacher = Users(uid="Bart", is_teacher=True, is_admin=False) + sel2_teacher = User(uid="Bart", is_teacher=True, is_admin=False) return sel2_teacher @pytest.fixture def course(course_teacher): """A course for testing, with the course teacher as the teacher.""" - sel2 = Courses(name="Sel2", teacher=course_teacher.uid) + sel2 = Course(name="Sel2", teacher=course_teacher.uid) return sel2 @pytest.fixture def course_students(): """A list of 5 students for testing.""" students = [ - Users(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) + User(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) for i in range(5) ] return students @@ -70,7 +70,7 @@ def course_students(): def course_students_relation(course,course_students): """A list of 5 course relations for testing.""" course_relations = [ - CourseStudents(course_id=course.course_id, uid=course_students[i].uid) + CourseStudent(course_id=course.course_id, uid=course_students[i].uid) for i in range(5) ] return course_relations @@ -78,20 +78,20 @@ def course_students_relation(course,course_students): @pytest.fixture def assistent(): """An assistent for testing.""" - assist = Users(uid="assistent_sel2") + assist = User(uid="assistent_sel2") return assist @pytest.fixture() def course_admin(course,assistent): """A course admin for testing.""" - admin_relation = CourseAdmins(uid=assistent.uid, course_id=course.course_id) + admin_relation = CourseAdmin(uid=assistent.uid, course_id=course.course_id) return admin_relation @pytest.fixture() def valid_project(): """A valid project for testing.""" deadline = datetime(2024, 2, 25, 12, 0, 0) # February 25, 2024, 12:00 PM - project = Projects( + project = Project( title="Project", descriptions="Test project", deadline=deadline, diff --git a/backend/tests/models/course_test.py b/backend/tests/models/course_test.py index 20898db8..8f6872ae 100644 --- a/backend/tests/models/course_test.py +++ b/backend/tests/models/course_test.py @@ -1,15 +1,15 @@ -"""Test module for the Courses model""" +"""Test module for the Course model""" import pytest from sqlalchemy.exc import IntegrityError -from project.models.courses import Courses -from project.models.users import Users -from project.models.course_relations import CourseAdmins, CourseStudents +from project.models.courses import Course +from project.models.users import User +from project.models.course_relations import CourseAdmin, CourseStudent -class TestCoursesModel: +class TestCourseModel: """Test class for the database models""" - def test_foreignkey_courses_teacher(self, db_session, course: Courses): + def test_foreignkey_courses_teacher(self, db_session, course: Course): """Tests the foreign key relation between courses and the teacher uid""" with pytest.raises( IntegrityError @@ -17,7 +17,7 @@ def test_foreignkey_courses_teacher(self, db_session, course: Courses): db_session.add(course) db_session.commit() - def test_correct_course(self, db_session, course: Courses, course_teacher: Users): + def test_correct_course(self, db_session, course: Course, course_teacher: User): """Tests wether added course and a teacher are correctly connected""" db_session.add(course_teacher) db_session.commit() @@ -25,7 +25,7 @@ def test_correct_course(self, db_session, course: Courses, course_teacher: Users db_session.add(course) db_session.commit() assert ( - db_session.query(Courses).filter_by(name=course.name).first().teacher + db_session.query(Course).filter_by(name=course.name).first().teacher == course_teacher.uid ) @@ -58,7 +58,7 @@ def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arg course_admin, ): """Tests if we get the expected results for - correct usage of CourseStudents and CourseAdmins""" + correct usage of CourseStudent and CourseAdmin""" db_session.add(course_teacher) db_session.commit() @@ -76,7 +76,7 @@ def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arg student_check = [ s.uid - for s in db_session.query(CourseStudents) + for s in db_session.query(CourseStudent) .filter_by(course_id=course.course_id) .all() ] @@ -90,7 +90,7 @@ def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arg db_session.commit() assert ( - db_session.query(CourseAdmins) + db_session.query(CourseAdmin) .filter_by(course_id=course.course_id) .first() .uid diff --git a/backend/tests/models/projects_and_submissions_test.py b/backend/tests/models/projects_and_submissions_test.py index 5f20aa1c..9c121df6 100644 --- a/backend/tests/models/projects_and_submissions_test.py +++ b/backend/tests/models/projects_and_submissions_test.py @@ -1,11 +1,11 @@ -"""This module tests the Projects and Submissions model""" +"""This module tests the Project and Submission model""" from datetime import datetime import pytest from sqlalchemy.exc import IntegrityError -from project.models.projects import Projects -from project.models.submissions import Submissions +from project.models.projects import Project +from project.models.submissions import Submission -class TestProjectsAndSubmissionsModel: # pylint: disable=too-few-public-methods +class TestProjectAndSubmissionModel: # pylint: disable=too-few-public-methods """Test class for the database models of projects and submissions""" def test_deadline(self,db_session, # pylint: disable=too-many-arguments ; all arguments are needed for the test course, @@ -22,13 +22,13 @@ def test_deadline(self,db_session, # pylint: disable=too-many-arguments ; all ar db_session.add(valid_project) db_session.commit() check_project = ( - db_session.query(Projects).filter_by(title=valid_project.title).first() + db_session.query(Project).filter_by(title=valid_project.title).first() ) assert check_project.deadline == valid_project.deadline db_session.add(valid_user) db_session.commit() - submission = Submissions( + submission = Submission( uid=valid_user.uid, project_id=check_project.project_id, submission_time=datetime.now(), @@ -39,7 +39,7 @@ def test_deadline(self,db_session, # pylint: disable=too-many-arguments ; all ar db_session.commit() submission_check = ( - db_session.query(Submissions) + db_session.query(Submission) .filter_by(project_id=check_project.project_id) .first() ) @@ -54,7 +54,7 @@ def test_deadline(self,db_session, # pylint: disable=too-many-arguments ; all ar submission_check.grading = 15 db_session.commit() submission_check = ( - db_session.query(Submissions) + db_session.query(Submission) .filter_by(project_id=check_project.project_id) .first() ) diff --git a/backend/tests/models/users_test.py b/backend/tests/models/users_test.py index efc7f579..9cca421d 100644 --- a/backend/tests/models/users_test.py +++ b/backend/tests/models/users_test.py @@ -1,7 +1,7 @@ """ -This file contains the tests for the Users model. +This file contains the tests for the User model. """ -from project.models.users import Users +from project.models.users import User class TestUserModel: @@ -11,7 +11,7 @@ def test_valid_user(self, db_session, valid_user): """Tests if a valid user can be added to the database.""" db_session.add(valid_user) db_session.commit() - assert valid_user in db_session.query(Users).all() + assert valid_user in db_session.query(User).all() def test_is_teacher(self, db_session, teachers): """Tests if the is_teacher field is correctly set to True @@ -19,7 +19,7 @@ def test_is_teacher(self, db_session, teachers): db_session.add_all(teachers) db_session.commit() teacher_count = 0 - for usr in db_session.query(Users).filter_by(is_teacher=True): + for usr in db_session.query(User).filter_by(is_teacher=True): teacher_count += 1 assert usr.is_teacher assert teacher_count == 10 From f918b18b6058493d147a4138527fb7931933c1ca Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Tue, 5 Mar 2024 11:31:09 +0100 Subject: [PATCH 065/377] Backend/feature/projects endpoint (#31) * route /projects/ added * added functionality for parsing the dict once the query is ran * display json data at endpoint instead of dummy data * detail endpoint created * post functionality for projects * remove functionality for projects, only works with updated sql script * added right exit codes * code reformat * added 404 page if you're trying to remove a non-existing project * 404 function made * added basic add and remove test for project * wrote extra test for just getting the users * added put for a project * added test for put * testing working * added comments to project_test project and projects_detail * added pytest fixture * added right dummy data for fixtures * linter fixes, project endpoint files now score 10/10 * more linter fixes * linter import order fix * added ingores for linter * more linter * renamed is_existing_project function * changed arguments of get functions to not use **kwargs anymore * fixed jsonify issue for response * added try catch blocks and changed Message to message * applied jsonify on projects * removed print statement * removed unnecessary session.commit * test if tests still work on git * added the commit messages again * better tests, pray for git tests * fixed TODO of path.append('.') * fixed code duplication for parser (hopefully) * fixed non existing fields in testing bug * fixed parse code duplication * linter fixes * more linter fixes * removed linter disables * fixed linter import order * removed unused imports * removed unused comment * removed print statement and unused comments * changed put to patch * fixed wrong status code * removed useless comment * added message field to return json * removed all fields from /projects get except project_id, title and descriptions * added try catch block to post of projects endpoint * removed print statements * removed commented code * changed the get from tuple to a json * linter fix * edited OpenAPI_Object * linter fix * fixed no module named project error * added file sessionmaker for code duplication purposes * Thanks for this linter for helping me improve my code :) * added root point conftest.py * added root point conftest.py * added try catch block * removed commented code * removed print statements * return whole URL of project instead of just the id * small linter fixes :) * load the env variable at the start of file as constant * paths attribute should be an object, not a list * patched patch function * fixed typo in patch * fixed json return in 500 patch message * fixed url returns * fix: wrapped project json with seperate message * fix: keyerror on tests with change of return json in projects POST method * fix: return good json representation * fixed typos in OpenAPI_Object.json * fixed typos in project_detail * fixed typos in model Projects * typo fixed * route -> routes * fix: naming of new database schemes * i <3 linters --------- Co-authored-by: Aron Buzogany --- backend/project/__init__.py | 2 + .../endpoints/index/OpenAPI_Object.json | 201 +++++++++++++++++- .../endpoints/projects/endpoint_parser.py | 31 +++ .../endpoints/projects/project_detail.py | 117 ++++++++++ .../endpoints/projects/project_endpoint.py | 23 ++ .../project/endpoints/projects/projects.py | 94 ++++++++ backend/project/models/projects.py | 33 +-- backend/project/sessionmaker.py | 18 ++ backend/run_tests.sh | 0 backend/tests/conftest.py | 18 ++ backend/tests/endpoints/conftest.py | 55 ++++- backend/tests/endpoints/index_test.py | 2 + backend/tests/endpoints/project_test.py | 92 ++++++++ backend/tests/models/conftest.py | 17 -- 14 files changed, 664 insertions(+), 39 deletions(-) create mode 100644 backend/project/endpoints/projects/endpoint_parser.py create mode 100644 backend/project/endpoints/projects/project_detail.py create mode 100644 backend/project/endpoints/projects/project_endpoint.py create mode 100644 backend/project/endpoints/projects/projects.py create mode 100644 backend/project/sessionmaker.py mode change 100644 => 100755 backend/run_tests.sh create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/endpoints/project_test.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index b970f5e1..33450700 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,7 @@ from flask import Flask from .db_in import db from .endpoints.index.index import index_bp +from .endpoints.projects.project_endpoint import project_bp from .endpoints.courses import courses_bp @@ -18,6 +19,7 @@ def create_app(): app = Flask(__name__) app.register_blueprint(index_bp) + app.register_blueprint(project_bp) app.register_blueprint(courses_bp) return app diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index ec8fb29e..4152afb6 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -42,7 +42,192 @@ ] }, "paths": { - + "/projects": { + "get": { + "description": "Returns all projects from the database that the user has access to", + "responses": { + "200": { + "description": "A list of projects", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "project_id": { + "type": "int" + }, + "descriptions": { + "type": "string" + }, + "title": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "post": { + "description": "Upload a new project", + "responses": { + "201": { + "description": "Uploaded a new project succesfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + } + } + } + }, + "/projects/{id}": { + "get": { + "description": "Return a project with corresponding id", + "responses": { + "200": { + "description": "A project with corresponding id", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "archieved": { + "type": "bool" + }, + "assignment_file": { + "type": "string" + }, + "course_id": { + "type": "int" + }, + "deadline": { + "type": "date" + }, + "descriptions": { + "type": "array", + "items": { + "description": "string" + } + }, + "project_id": { + "type": "int" + }, + "regex_expressions": { + "type": "array", + "items": { + "regex": "string" + } + }, + "script_name": { + "type": "string" + }, + "test_path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "visible_for_students": { + "type": "bool" + } + } + } + } + } + }, + "404": { + "description": "An id that doesn't correspond to an existing project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "patch": { + "description": "Patch certain fields of a project", + "responses": { + "200": { + "description": "Patched a project succesfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + }, + "404": { + "description": "Tried to patch a project that is not present", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + }, + "delete": { + "description": "Delete a project with given id", + "responses": { + "200": { + "description": "Removed a project succesfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + }, + "404": { + "description": "Tried to remove a project that is not present", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": "string" + } + } + } + } + } + } + } + }, "/courses": { "get": { "description": "Get a list of all courses.", @@ -85,7 +270,7 @@ } } } - } + } }, "parameters": [ { @@ -111,7 +296,7 @@ "schema": { "type": "string" } - } + } ] }, "post": { @@ -611,7 +796,7 @@ } }, "post":{ - "description": "Assign students to a course.", + "description": "Assign students to a course.", "parameters": [ { "name": "course_id", @@ -733,7 +918,7 @@ } } } - } + } }, "delete":{ "description": "Remove students from a course.", @@ -918,7 +1103,7 @@ } }, "post":{ - "description": "Assign admins to a course.", + "description": "Assign admins to a course.", "parameters": [ { "name": "course_id", @@ -1040,7 +1225,7 @@ } } } - } + } }, "delete":{ "description": "Remove an admin from a course.", @@ -1156,6 +1341,6 @@ } } } - + } } diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py new file mode 100644 index 00000000..87f61e69 --- /dev/null +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -0,0 +1,31 @@ +""" +Parser for the argument when posting or patching a project +""" + +from flask_restful import reqparse + +parser = reqparse.RequestParser() +parser.add_argument('title', type=str, help='Projects title') +parser.add_argument('descriptions', type=str, help='Projects description') +parser.add_argument('assignment_file', type=str, help='Projects assignment file') +parser.add_argument("deadline", type=str, help='Projects deadline') +parser.add_argument("course_id", type=str, help='Projects course_id') +parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students') +parser.add_argument("archieved", type=bool, help='Projects') +parser.add_argument("test_path", type=str, help='Projects test path') +parser.add_argument("script_name", type=str, help='Projects test script path') +parser.add_argument("regex_expressions", type=str, help='Projects regex expressions') + + +def parse_project_params(): + """ + Return a dict of every non None value in the param + """ + args = parser.parse_args() + result_dict = {} + + for key, value in args.items(): + if value is not None: + result_dict[key] = value + + return result_dict diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py new file mode 100644 index 00000000..88989247 --- /dev/null +++ b/backend/project/endpoints/projects/project_detail.py @@ -0,0 +1,117 @@ +""" +Module for project details page +for example /projects/1 if the project id of +the corresponding project is 1 +""" +from os import getenv +from dotenv import load_dotenv + +from flask import jsonify +from flask_restful import Resource, abort +from sqlalchemy import exc +from project.endpoints.projects.endpoint_parser import parse_project_params + +from project import db +from project.models.projects import Project + +load_dotenv() +API_URL = getenv('API_HOST') + +class ProjectDetail(Resource): + """ + Class for projects/id endpoints + Inherits from flask_restful.Resource class + for implementing get, delete and put methods + """ + + def abort_if_not_present(self, project): + """ + Check if the project exists in the database + and if not abort the request and give back a 404 not found + """ + if project is None: + abort(404) + + def get(self, project_id): + """ + Get method for listing a specific project + filtered by id of that specific project + the id fetched from the url with the reaparse + """ + + try: + # fetch the project with the id that is specified in the url + project = Project.query.filter_by(project_id=project_id).first() + self.abort_if_not_present(project) + + # return the fetched project and return 200 OK status + return { + "data": jsonify(project).json, + "url": f"{API_URL}/projects/{project_id}", + "message": "Got project successfully" + }, 200 + except exc.SQLAlchemyError: + return { + "message": "Internal server error", + "url": f"{API_URL}/projects/{project_id}" + }, 500 + + def patch(self, project_id): + """ + Update method for updating a specific project + filtered by id of that specific project + """ + + # get the project that need to be edited + project = Project.query.filter_by(project_id=project_id).first() + + # check which values are not None in the dict + # if it is not None it needs to be modified in the database + + # commit the changes and return the 200 OK code if it succeeds, else 500 + try: + var_dict = parse_project_params() + for key, value in var_dict.items(): + setattr(project, key, value) + db.session.commit() + # get the updated version + return { + "message": f"Succesfully changed project with id: {id}", + "url": f"{API_URL}/projects/{id}", + "data": project + }, 200 + except exc.SQLAlchemyError: + db.session.rollback() + return { + "message": f"Something unexpected happenend when trying to edit project {id}", + "url": f"{API_URL}/projects/{id}" + }, 500 + + def delete(self, project_id): + """ + Delete a project and all of its submissions in cascade + done by project id + """ + + # fetch the project that needs to be removed + deleted_project = Project.query.filter_by(project_id=project_id).first() + + # check if its an existing one + self.abort_if_not_present(deleted_project) + + # if it exists delete it and commit the changes in the database + try: + db.session.delete(deleted_project) + db.session.commit() + + # return 200 if content is deleted succesfully + return { + "message": f"Project with id: {id} deleted successfully", + "url": f"{API_URL}/projects/{id} deleted successfully!", + "data": deleted_project + }, 200 + except exc.SQLAlchemyError: + return { + "message": f"Something unexpected happened when removing project {project_id}", + "url": f"{API_URL}/projects/{id}" + }, 500 diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py new file mode 100644 index 00000000..c996a514 --- /dev/null +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -0,0 +1,23 @@ +""" +Module for providing the blueprint to the api +of both routes +""" + +from flask import Blueprint +from flask_restful import Api + +from project.endpoints.projects.projects import ProjectsEndpoint +from project.endpoints.projects.project_detail import ProjectDetail + +project_bp = Blueprint('project_endpoint', __name__) +project_endpoint = Api(project_bp) + +project_bp.add_url_rule( + '/projects', + view_func=ProjectsEndpoint.as_view('project_endpoint') +) + +project_bp.add_url_rule( + '/projects/', + view_func=ProjectDetail.as_view('project_detail') +) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py new file mode 100644 index 00000000..f444e283 --- /dev/null +++ b/backend/project/endpoints/projects/projects.py @@ -0,0 +1,94 @@ +""" +Module that implements the /projects endpoint of the API +""" +from os import getenv +from dotenv import load_dotenv + +from flask import jsonify +from flask_restful import Resource +from sqlalchemy import exc + + +from project import db +from project.models.projects import Project +from project.endpoints.projects.endpoint_parser import parse_project_params + +load_dotenv() +API_URL = getenv('API_HOST') + +class ProjectsEndpoint(Resource): + """ + Class for projects endpoints + Inherits from flask_restful.Resource class + for implementing get method + """ + + def get(self): + """ + Get method for listing all available projects + that are currently in the API + """ + try: + projects = Project.query.with_entities( + Project.project_id, + Project.title, + Project.descriptions + ).all() + + results = [{ + "project_id": row[0], + "title": row[1], + "descriptions": row[2] + } for row in projects] + + # return all valid entries for a project and return a 200 OK code + return { + "data": results, + "url": f"{API_URL}/projects", + "message": "Projects fetched successfully" + }, 200 + except exc.SQLAlchemyError: + return { + "message": "Something unexpected happenend when trying to get the projects", + "url": f"{API_URL}/projects" + }, 500 + + def post(self): + """ + Post functionality for project + using flask_restfull parse lib + """ + args = parse_project_params() + + # create a new project object to add in the API later + new_project = Project( + title=args['title'], + descriptions=args['descriptions'], + assignment_file=args['assignment_file'], + deadline=args['deadline'], + course_id=args['course_id'], + visible_for_students=args['visible_for_students'], + archieved=args['archieved'], + test_path=args['test_path'], + script_name=args['script_name'], + regex_expressions=args['regex_expressions'] + ) + + # add the new project to the database and commit the changes + + try: + db.session.add(new_project) + db.session.commit() + new_project_json = jsonify(new_project).json + + return { + "url": f"{API_URL}/projects/{new_project_json['project_id']}", + "message": "Project posted successfully", + "data": new_project_json + }, 201 + except exc.SQLAlchemyError: + return ({ + "url": f"{API_URL}/projects", + "message": "Something unexpected happenend when trying to add a new project", + "data": jsonify(new_project).json + }, 500) diff --git a/backend/project/models/projects.py b/backend/project/models/projects.py index b31066d3..5171e1e6 100644 --- a/backend/project/models/projects.py +++ b/backend/project/models/projects.py @@ -1,8 +1,11 @@ """Model for projects""" +import dataclasses + from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from project import db -class Project(db.Model): +@dataclasses.dataclass +class Project(db.Model): # pylint: disable=too-many-instance-attributes """This class describes the projects table, a projects has an id, a title, a description, an optional assignment file that can contain more explanation of the projects, @@ -10,17 +13,21 @@ class Project(db.Model): the course id of the course to which the project belongs, visible for students variable so a teacher can decide if the students can see it yet, archieved var so we can implement the archiving functionality, - a test path,script name and regex experssions for automated testing""" + a test path,script name and regex expressions for automated testing + + Pylint disable too many instance attributes because we can't reduce the amount + of fields of the model + """ __tablename__ = "projects" - project_id = Column(Integer, primary_key=True) - title = Column(String(50), nullable=False, unique=False) - descriptions = Column(Text, nullable=False) - assignment_file = Column(String(50)) - deadline = Column(DateTime(timezone=True)) - course_id = Column(Integer, ForeignKey("courses.course_id"), nullable=False) - visible_for_students = Column(Boolean, nullable=False) - archieved = Column(Boolean, nullable=False) - test_path = Column(String(50)) - script_name = Column(String(50)) - regex_expressions = Column(ARRAY(String(50))) + project_id: int = Column(Integer, primary_key=True) + title: str = Column(String(50), nullable=False, unique=False) + descriptions: str = Column(Text, nullable=False) + assignment_file: str = Column(String(50)) + deadline: str = Column(DateTime(timezone=True)) + course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) + visible_for_students: bool = Column(Boolean, nullable=False) + archieved: bool = Column(Boolean, nullable=False) + test_path: str = Column(String(50)) + script_name: str = Column(String(50)) + regex_expressions: list[str] = Column(ARRAY(String(50))) diff --git a/backend/project/sessionmaker.py b/backend/project/sessionmaker.py new file mode 100644 index 00000000..9fbf1cad --- /dev/null +++ b/backend/project/sessionmaker.py @@ -0,0 +1,18 @@ +"""initialise a datab session""" +from os import getenv +from dotenv import load_dotenv +from sqlalchemy import create_engine, URL +from sqlalchemy.orm import sessionmaker + +load_dotenv() + +url = URL.create( + drivername="postgresql", + username=getenv("POSTGRES_USER"), + password=getenv("POSTGRES_PASSWORD"), + host=getenv("POSTGRES_HOST"), + database=getenv("POSTGRES_DB") +) + +engine = create_engine(url) +Session = sessionmaker(bind=engine) diff --git a/backend/run_tests.sh b/backend/run_tests.sh old mode 100644 new mode 100755 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..148ef6f2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,18 @@ +"""root level fixtures""" +import pytest +from project.sessionmaker import engine, Session +from project import db + +@pytest.fixture +def db_session(): + """Create a new database session for a test. + After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) + session = Session() + yield session + session.rollback() + session.close() + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 4e787ad9..0e964c22 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,14 +1,67 @@ -"""Configuration for pytest, Flask, and the test client.""" +""" Configuration for pytest, Flask, and the test client.""" +from datetime import datetime import os import pytest from project.models.courses import Course from project.models.users import User +from project.models.projects import Project from project.models.course_relations import CourseStudent,CourseAdmin from project import create_app_with_db, db from project.db_in import url +@pytest.fixture +def course_teacher_ad(): + """A user that's a teacher for testing""" + ad_teacher = User(uid="Gunnar", is_teacher=True, is_admin=True) + return ad_teacher + + +@pytest.fixture +def course_ad(course_teacher_ad: User): + """A course for testing, with the course teacher as the teacher.""" + ad2 = Course(name="Ad2", teacher=course_teacher_ad.uid) + return ad2 + + +@pytest.fixture +def project(course): + """A project for testing, with the course as the course it belongs to""" + date = datetime(2024, 2, 25, 12, 0, 0) + project = Project( + title="Project", + descriptions="Test project", + course_id=course.course_id, + assignment_file="testfile", + deadline=date, + visible_for_students=True, + archieved=False, + test_path="testpad", + script_name="testscript", + regex_expressions='r' + ) + return project + + +@pytest.fixture +def project_json(project: Project): + """A function that return the json data of a project including the PK neede for testing""" + data = { + "title": project.title, + "descriptions": project.descriptions, + "assignment_file": project.assignment_file, + "deadline": project.deadline, + "course_id": project.course_id, + "visible_for_students": project.visible_for_students, + "archieved": project.archieved, + "test_path": project.test_path, + "script_name": project.script_name, + "regex_expressions": project.regex_expressions + } + return data + + @pytest.fixture def api_url(): """Get the API URL from the environment.""" diff --git a/backend/tests/endpoints/index_test.py b/backend/tests/endpoints/index_test.py index 5624bba7..8f3a5d4e 100644 --- a/backend/tests/endpoints/index_test.py +++ b/backend/tests/endpoints/index_test.py @@ -1,10 +1,12 @@ """Test the base routes of the application""" + def test_home(client): """Test whether the index page is accesible""" response = client.get("/") assert response.status_code == 200 + def test_openapi_spec(client): """Test whether the required fields of the openapi spec are present""" response = client.get("/") diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py new file mode 100644 index 00000000..1ebecce4 --- /dev/null +++ b/backend/tests/endpoints/project_test.py @@ -0,0 +1,92 @@ +"""Tests for project endpoints.""" +from project.models.projects import Project + +def test_projects_home(client): + """Test home project endpoint.""" + response = client.get("/projects") + assert response.status_code == 200 + + +def test_getting_all_projects(client): + """Test getting all projects""" + response = client.get("/projects") + assert response.status_code == 200 + assert isinstance(response.json['data'], list) + + +def test_post_project(db_session, client, course_ad, course_teacher_ad, project_json): + """Test posting a project to the database and testing if it's present""" + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + + project_json["course_id"] = course_ad.course_id + + # post the project + response = client.post("/projects", json=project_json) + assert response.status_code == 201 + + # check if the project with the id is present + project_id = response.json["data"]["project_id"] + response = client.get(f"/projects/{project_id}") + + assert response.status_code == 200 + + +def test_remove_project(db_session, client, course_ad, course_teacher_ad, project_json): + """Test removing a project to the datab and fetching it, testing if it's not present anymore""" + + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + + project_json["course_id"] = course_ad.course_id + + # post the project + response = client.post("/projects", json=project_json) + + # check if the project with the id is present + project_id = response.json["data"]["project_id"] + + response = client.delete(f"/projects/{project_id}") + assert response.status_code == 200 + + # check if the project isn't present anymore and the delete indeed went through + response = client.delete(f"/projects/{project_id}") + assert response.status_code == 404 + + +def test_patch_project(db_session, client, course_ad, course_teacher_ad, project): + """ + Test functionality of the PUT method for projects + """ + + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + + project.course_id = course_ad.course_id + + # post the project to edit + db_session.add(project) + db_session.commit() + project_id = project.project_id + + new_title = "patched title" + new_archieved = not project.archieved + + response = client.patch(f"/projects/{project_id}", json={ + "title": new_title, "archieved": new_archieved + }) + db_session.commit() + updated_project = db_session.get(Project, {"project_id": project.project_id}) + + assert response.status_code == 200 + assert updated_project.title == new_title + assert updated_project.archieved == new_archieved diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 7bd5e2a0..4ed9bdcf 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -6,7 +6,6 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import pytest -from project import db from project.models.courses import Course from project.models.course_relations import CourseAdmin, CourseStudent from project.models.projects import Project @@ -16,22 +15,6 @@ engine = create_engine(url) Session = sessionmaker(bind=engine) -@pytest.fixture -def db_session(): - """Create a new database session for a test. - After the test, all changes are rolled back and the session is closed.""" - db.metadata.create_all(engine) - session = Session() - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() - yield session - session.rollback() - session.close() - # Truncate all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() @pytest.fixture def valid_user(): From cc49d3bef21e21a562d3661f613aed5837da5a50 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Tue, 5 Mar 2024 14:56:32 +0100 Subject: [PATCH 066/377] added readme.md --- backend/README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/README.md diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..e69de29b From 8cf3d3852cdce79c8f3875492b73751540507b91 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Tue, 5 Mar 2024 15:44:51 +0100 Subject: [PATCH 067/377] first draft of readme.md --- backend/README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/backend/README.md b/backend/README.md index e69de29b..fe6c916d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -0,0 +1,52 @@ +# Project pigeonhole backend +## Prerequisites +If you want the development environment run both commands if you only need to deploy only run the Deploment command. +- Deployment +```sh + pip install -r requirments.txt +``` +- Development +```sh + pip install -r dev-requirments.txt +``` + +## Installation +1. Clone the repo + ```sh + git clone git@github.com:SELab-2/UGent-3.git + ``` +2. If you want to development run both commands, if you want to deploy only run deployment command. + - Deployment + ```sh + pip install -r requirments.txt + ``` + - Development + ```sh + pip install -r dev-requirments.txt + ``` + +## Setting up the environment variables +The project requires a couple of environment variables to run, +these should be located in your own .env file if you want to develop on this codebase. + +| Variable | Description | +|-------------------|----------------------------------------------------------------| +| DB_HOST | Url of where the database is located | +| POSTGRES_USER | Name of the user, needed to login to the postgres database | +| POSTGRES_PASSWORD | Password of the user, needed to login to the postgres database | +| POSTGRES_HOST | IP adress of the postgres database | +| POSTGRES_DB | Name of the postgres database | +| API_HOST | Location of the API root | + +All the variables except the last one are for the database setup, +these are needed or else you can't make a valid connection. +The last one is for keeping the API restfull since the location of the recourse should be located. + +## Running the project +Once all the setup is done you can start the development server by +navigating to the backend directory and running: +```sh +python project +``` +The server should now be located at localhost:5000 and you can +start developping. From dadff8bf82d6639a3ee745ca2bedc31ed0e4403b Mon Sep 17 00:00:00 2001 From: gerwoud Date: Tue, 5 Mar 2024 17:12:35 +0100 Subject: [PATCH 068/377] continueing on readme --- backend/README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/backend/README.md b/backend/README.md index fe6c916d..3173200b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,11 @@ # Project pigeonhole backend ## Prerequisites If you want the development environment run both commands if you only need to deploy only run the Deploment command. + +The dev-requirements.txt contains everything for writing tests and linters for maintaining quality code. +On the other hand the regular requirements.txt install the packages needed for +the regular base application. + - Deployment ```sh pip install -r requirments.txt @@ -50,3 +55,31 @@ python project ``` The server should now be located at localhost:5000 and you can start developping. + +## Maintaining the codebase +### Writing tests +When writing new code it is important to maintain the right functionality so +writing tests is mandatory for this, the test library used in this codebase is pytest. + +- pytest documentation: https://docs.pytest.org/en/8.0.x/ + +If you want to write tests we highly advise to read the pytest documentation on how +to write tests, so they are kept conventional. + +For executing the tests and testing you're newly added functionality (and to test if you broke nothing from earlier working code) +you can run: +```sh +sudo ./run_tests.sh +``` + +Located in the backend directory. +### Running the linter +This codebase is kept by the pylint linter. +- pylint docutmentation: https://pypi.org/project/pylint/ + +If you want to execute the linter on all .py files in the project it can simply be done +with the command: +```sh +find . -type f -name "*.py" | xargs pylint +``` +The code needs to get a 10/10 score to get pushed to the repository. From 249fcdd433186d1362c691ed7e1f19d9c9775c42 Mon Sep 17 00:00:00 2001 From: cmekeirl Date: Wed, 6 Mar 2024 10:50:16 +0100 Subject: [PATCH 069/377] on /courses return all data --- backend/project/endpoints/courses.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index 1903e9e3..c23bf8f1 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -253,12 +253,17 @@ def get(self): results = execute_query_abort_if_db_error( query, url=API_URL + "/courses", query_all=True ) - detail_urls = [ - f"{API_URL}/courses/{str(course.course_id)}" for course in results + courses = [{ + "url": f"{API_URL}/courses/{str(course.course_id)}", + "name": course.name, + "teacher": course.teacher, + "ufora_id" : course.ufora_id if course.ufora_id else "None" + } + for course in results ] message = "Succesfully retrieved all courses with given parameters" response = json_message(message) - response["data"] = detail_urls + response["data"] = courses response["url"] = API_URL + "/courses" return response From 8f3a67432ee7d5e13d9e8d9c688685a49613b10d Mon Sep 17 00:00:00 2001 From: cmekeirl Date: Wed, 6 Mar 2024 12:12:32 +0100 Subject: [PATCH 070/377] better code same result --- backend/project/endpoints/courses.py | 15 ++++++--------- backend/project/models/courses.py | 17 ++++++++++------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index c23bf8f1..917c0c0b 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -2,6 +2,8 @@ from os import getenv from dotenv import load_dotenv +from typing import List +import dataclasses from flask import Blueprint, jsonify, request from flask import abort from flask_restful import Api, Resource @@ -250,22 +252,17 @@ def get(self): query = query.filter_by(ufora_id=request.args.get("ufora_id")) if "name" in request.args: query = query.filter_by(name=request.args.get("name")) - results = execute_query_abort_if_db_error( + results:List[Course] = execute_query_abort_if_db_error( query, url=API_URL + "/courses", query_all=True ) - courses = [{ - "url": f"{API_URL}/courses/{str(course.course_id)}", - "name": course.name, - "teacher": course.teacher, - "ufora_id" : course.ufora_id if course.ufora_id else "None" - } - for course in results + courses = [ + dataclasses.asdict(course) for course in results ] message = "Succesfully retrieved all courses with given parameters" response = json_message(message) response["data"] = courses response["url"] = API_URL + "/courses" - return response + return jsonify(response) def post(self): """ diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index dc778706..3e0f9ef8 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,14 +1,17 @@ -"""The Course model""" - from sqlalchemy import Integer, Column, ForeignKey, String from project import db +from dataclasses import dataclass + +"""The Course model""" + +@dataclass class Course(db.Model): """This class described the courses table, a course has an id, name, optional ufora id and the teacher that created it""" - __tablename__ = "courses" - course_id = Column(Integer, primary_key=True) - name = Column(String(50), nullable=False) - ufora_id = Column(String(50), nullable=True) - teacher = Column(String(255), ForeignKey("users.uid"), nullable=False) + __tablename__: str = "courses" + course_id: int = Column(Integer, primary_key=True) + name: str = Column(String(50), nullable=False) + ufora_id: str = Column(String(50), nullable=True) + teacher: str = Column(String(255), ForeignKey("users.uid"), nullable=False) From 3043099fa9044d9dcb1f3012843c01089966f873 Mon Sep 17 00:00:00 2001 From: cmekeirl Date: Wed, 6 Mar 2024 12:20:40 +0100 Subject: [PATCH 071/377] linter response fix --- backend/project/endpoints/courses.py | 8 +++++--- backend/project/models/courses.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index 917c0c0b..2916887c 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -1,9 +1,9 @@ """Course api point""" from os import getenv -from dotenv import load_dotenv -from typing import List import dataclasses +from typing import List +from dotenv import load_dotenv from flask import Blueprint, jsonify, request from flask import abort from flask_restful import Api, Resource @@ -256,7 +256,9 @@ def get(self): query, url=API_URL + "/courses", query_all=True ) courses = [ - dataclasses.asdict(course) for course in results + {**dataclasses.asdict(course), + "url":f"{API_URL}/courses/{course.course_id}"} + for course in results ] message = "Succesfully retrieved all courses with given parameters" response = json_message(message) diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index 3e0f9ef8..601116ed 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,6 +1,6 @@ +from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String from project import db -from dataclasses import dataclass """The Course model""" @@ -10,7 +10,7 @@ class Course(db.Model): """This class described the courses table, a course has an id, name, optional ufora id and the teacher that created it""" - __tablename__: str = "courses" + __tablename__ = "courses" course_id: int = Column(Integer, primary_key=True) name: str = Column(String(50), nullable=False) ufora_id: str = Column(String(50), nullable=True) From a5fba2b0e2da6d51b4d8cce0cbc1417ea65d10b7 Mon Sep 17 00:00:00 2001 From: cmekeirl Date: Wed, 6 Mar 2024 12:22:54 +0100 Subject: [PATCH 072/377] linter --- backend/project/endpoints/courses.py | 2 +- backend/project/models/courses.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index 2916887c..4a4def72 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -257,7 +257,7 @@ def get(self): ) courses = [ {**dataclasses.asdict(course), - "url":f"{API_URL}/courses/{course.course_id}"} + "url":f"{API_URL}/courses/{course.course_id}"} for course in results ] message = "Succesfully retrieved all courses with given parameters" diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index 601116ed..052e5d5f 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,9 +1,8 @@ +"""The Course model""" from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String from project import db -"""The Course model""" - @dataclass class Course(db.Model): From 12830d56134c0bec58a0c147184de0f614e59dab Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 13:42:58 +0100 Subject: [PATCH 073/377] added query agent containing functions that can be used by multiple endpoints --- backend/project/utils/query_agent.py | 119 +++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 backend/project/utils/query_agent.py diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py new file mode 100644 index 00000000..947089e9 --- /dev/null +++ b/backend/project/utils/query_agent.py @@ -0,0 +1,119 @@ +""" +This module contains the functions to interact with the database. It contains functions to +delete, insert and query entries from the database. The functions are used by the routes +to interact with the database. +""" + +from typing import Dict, List, Union +from flask import jsonify +from sqlalchemy import and_ +from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.orm.query import Query +from sqlalchemy.exc import SQLAlchemyError +from project.db_in import db + +def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int): + """ + Deletes an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to delete from. + column_name: str - The name of the column to delete from. + id: int - The id of the entry to delete. + + Returns: + A message indicating that the resource was deleted successfully if the operation was + successful, otherwise a message indicating that something went wrong while deleting from + the database. + """ + try: + result: DeclarativeMeta = model.query.filter( + getattr(model, column_name) == column_id + ).first() + + if not result: + return {"message": "Resource not found"}, 404 + db.session.delete(result) + db.session.commit() + return {"message": "Resource deleted successfully"}, 200 + except SQLAlchemyError: + return {"error": "Something went wrong while deleting from the database."}, 500 + +def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): + """ + Inserts a new entry into the database giving the model corresponding to a certain table + and the data to insert. + + Args: + model: DeclarativeMeta - The model corresponding to the table to insert into. + data: Dict[str, Union[str, int]] - The data to insert into the table. + + Returns: + The new entry inserted into the database if the operation was successful, otherwise + a message indicating that something went wrong while inserting into the database. + """ + try: + new_instance: DeclarativeMeta = model(**data) + db.session.add(new_instance) + db.session.commit() + return new_instance, 201 + except SQLAlchemyError: + return {"error": "Something went wrong while inserting into the database."}, 500 + +def query_selected_from_model(model: DeclarativeMeta, + select_values: List[str] = None, + filters: Dict[str, Union[str, int]]=None): + """ + Query all entries from the database giving the model corresponding to a certain table, + the columns to select and the filters to apply. + + Args: + model: DeclarativeMeta - The model corresponding to the table to query from. + select_values: List[str] - The values to select from the table. + filters: Dict[str, Union[str, int]] - The filters to apply to the query. + + Returns: + The entries queried from the database if they exist, otherwise a message indicating + that the resource was not found. + """ + try: + query: Query = model.query + if select_values: + query = query.with_entities(*[getattr(model, value) for value in select_values]) + if filters: + conditions: List[bool] = [] + for key, value in filters.items(): + conditions.append(getattr(model, key) == value) + query = query.filter(and_(*conditions)) + results: List[DeclarativeMeta] = query.all() + return jsonify(results), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while querying the database."}, 500 + +def query_by_id_from_model(model: DeclarativeMeta, + column_name: str, + column_id: int, + not_found_message: str="Resource not found"): + """ + Query an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to query from. + column_name: str - The name of the column to query from. + id: int - The id of the entry to query. + not_found_message: str - The message to return if the entry is not found. + + Returns: + The entry queried from the database if it exists, otherwise a message indicating + that the resource was not found. + + """ + try: + result: Query = model.query.filter(getattr(model, column_name) == column_id).first() + if not result: + return {"message": not_found_message}, 404 + return jsonify(result), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while querying the database."}, 500 From a8497ea3f44ea6792fe83d1b211e5849719d6b74 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 15:07:44 +0100 Subject: [PATCH 074/377] type development -> develop --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index 3173200b..3a1337ec 100644 --- a/backend/README.md +++ b/backend/README.md @@ -20,7 +20,7 @@ the regular base application. ```sh git clone git@github.com:SELab-2/UGent-3.git ``` -2. If you want to development run both commands, if you want to deploy only run deployment command. +2. If you want to develop run both commands, if you want to deploy only run deployment command. - Deployment ```sh pip install -r requirments.txt From c66a3b27764f809aa0d4ec6117639f87a5e5500b Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 15:09:51 +0100 Subject: [PATCH 075/377] removed links and replaced them with clickable text placeholders --- backend/README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/README.md b/backend/README.md index 3a1337ec..67982866 100644 --- a/backend/README.md +++ b/backend/README.md @@ -59,9 +59,7 @@ start developping. ## Maintaining the codebase ### Writing tests When writing new code it is important to maintain the right functionality so -writing tests is mandatory for this, the test library used in this codebase is pytest. - -- pytest documentation: https://docs.pytest.org/en/8.0.x/ +writing tests is mandatory for this, the test library used in this codebase is [pytest](https://docs.pytest.org/en/8.0.x/). If you want to write tests we highly advise to read the pytest documentation on how to write tests, so they are kept conventional. @@ -74,8 +72,7 @@ sudo ./run_tests.sh Located in the backend directory. ### Running the linter -This codebase is kept by the pylint linter. -- pylint docutmentation: https://pypi.org/project/pylint/ +This codebase is kept clean by the [pylint](https://pypi.org/project/pylint/) linter. If you want to execute the linter on all .py files in the project it can simply be done with the command: From 81e3d3c955c3e483d792bff84f097d7f327fbb4c Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:25:29 +0100 Subject: [PATCH 076/377] loading env variables is only necessary in __main__ --- backend/project/__main__.py | 5 ++--- backend/project/endpoints/courses.py | 2 -- backend/project/endpoints/projects/project_detail.py | 2 -- backend/project/sessionmaker.py | 3 --- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 2f312c85..a4bd122b 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,11 +1,10 @@ """Main entry point for the application.""" -from sys import path +from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url -path.append(".") - if __name__ == "__main__": + load_dotenv() app = create_app_with_db(url) app.run(debug=True) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index 1903e9e3..a09e7cfb 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -1,7 +1,6 @@ """Course api point""" from os import getenv -from dotenv import load_dotenv from flask import Blueprint, jsonify, request from flask import abort from flask_restful import Api, Resource @@ -15,7 +14,6 @@ courses_bp = Blueprint("courses", __name__) courses_api = Api(courses_bp) -load_dotenv() API_URL = getenv("API_HOST") diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 88989247..5428dcac 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -4,7 +4,6 @@ the corresponding project is 1 """ from os import getenv -from dotenv import load_dotenv from flask import jsonify from flask_restful import Resource, abort @@ -14,7 +13,6 @@ from project import db from project.models.projects import Project -load_dotenv() API_URL = getenv('API_HOST') class ProjectDetail(Resource): diff --git a/backend/project/sessionmaker.py b/backend/project/sessionmaker.py index 9fbf1cad..0ab68f8e 100644 --- a/backend/project/sessionmaker.py +++ b/backend/project/sessionmaker.py @@ -1,11 +1,8 @@ """initialise a datab session""" from os import getenv -from dotenv import load_dotenv from sqlalchemy import create_engine, URL from sqlalchemy.orm import sessionmaker -load_dotenv() - url = URL.create( drivername="postgresql", username=getenv("POSTGRES_USER"), From 658dfd35b355638b08c51d01b19c2352ae629fca Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:30:16 +0100 Subject: [PATCH 077/377] removed unneeded load_dotenv --- backend/project/db_in.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/backend/project/db_in.py b/backend/project/db_in.py index ebcc02dd..57a572fa 100644 --- a/backend/project/db_in.py +++ b/backend/project/db_in.py @@ -2,13 +2,10 @@ import os from flask_sqlalchemy import SQLAlchemy -from dotenv import load_dotenv from sqlalchemy import URL db = SQLAlchemy() -load_dotenv() - DATABSE_NAME = os.getenv("POSTGRES_DB") DATABASE_USER = os.getenv("POSTGRES_USER") DATABASE_PASSWORD = os.getenv("POSTGRES_PASSWORD") From 7dd56821d8141508d48aecd0d44fde32986c6a35 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:32:46 +0100 Subject: [PATCH 078/377] completed functions that are ought to be used by multiple endpoints or files --- backend/project/utils/misc.py | 38 +++++++++++++++++++++ backend/project/utils/query_agent.py | 50 ++++++++++++++++++++++------ 2 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 backend/project/utils/misc.py diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py new file mode 100644 index 00000000..3c348175 --- /dev/null +++ b/backend/project/utils/misc.py @@ -0,0 +1,38 @@ +from typing import Dict, List +from urllib.parse import urljoin + +def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[str, str]: + """ + Maps keys to a url using a url mapper. + + Args: + url_mapper: Dict[str, str] - A dictionary that maps keys to urls. + data: Dict[str, str] - The data to map to urls. + + Returns: + A dictionary with the keys mapped to the urls. + """ + for key, value in data.items(): + if key in url_mapper: + data[key] = urljoin(url_mapper[key], str(value)) + return data + +def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): + """ + Maps all keys to a url using a url mapper. + + Args: + url_mapper: Dict[str, str] - A dictionary that maps keys to urls. + data: List[Dict[str, str]] - The data to map to urls. + + Returns: + A list of dictionaries with the keys mapped to the urls. + """ + print(data) + return [map_keys_to_url(url_mapper, entry) for entry in data] + +def model_to_dict(instance): + return {column.key: getattr(instance, column.key) for column in instance.__table__.columns} + +def models_to_dict(instances): + return [model_to_dict(instance) for instance in instances] \ No newline at end of file diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 947089e9..9b348350 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -5,12 +5,14 @@ """ from typing import Dict, List, Union +from urllib.parse import urljoin from flask import jsonify from sqlalchemy import and_ from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError from project.db_in import db +from project.utils.misc import map_all_keys_to_url, models_to_dict def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int): """ @@ -40,7 +42,9 @@ def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: except SQLAlchemyError: return {"error": "Something went wrong while deleting from the database."}, 500 -def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): +def insert_into_model(model: DeclarativeMeta, + data: Dict[str, Union[str, int]], + response_url_base: str): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -48,6 +52,7 @@ def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): Args: model: DeclarativeMeta - The model corresponding to the table to insert into. data: Dict[str, Union[str, int]] - The data to insert into the table. + response_url_base: str - The base url to use in the response. Returns: The new entry inserted into the database if the operation was successful, otherwise @@ -57,20 +62,28 @@ def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]]): new_instance: DeclarativeMeta = model(**data) db.session.add(new_instance) db.session.commit() - return new_instance, 201 + return {"data": new_instance, + "message": "Object created succesfully.", + "url": urljoin(response_url_base, str(new_instance.project_id))}, 201 except SQLAlchemyError: - return {"error": "Something went wrong while inserting into the database."}, 500 + return {"error": "Something went wrong while inserting into the database.", + "url": response_url_base}, 500 def query_selected_from_model(model: DeclarativeMeta, + response_url: str, + url_mapper: Dict[str, str] = None, select_values: List[str] = None, filters: Dict[str, Union[str, int]]=None): """ - Query all entries from the database giving the model corresponding to a certain table, - the columns to select and the filters to apply. + Query entries from the database giving the model corresponding to a certain table and + the filters to apply to the query. + Args: model: DeclarativeMeta - The model corresponding to the table to query from. - select_values: List[str] - The values to select from the table. + response_url: str - The base url to use in the response. + url_mapper: Dict[str, str] - A dictionary to map the keys of the response to urls. + select_values: List[str] - The columns to select from the table. filters: Dict[str, Union[str, int]] - The filters to apply to the query. Returns: @@ -79,17 +92,32 @@ def query_selected_from_model(model: DeclarativeMeta, """ try: query: Query = model.query - if select_values: - query = query.with_entities(*[getattr(model, value) for value in select_values]) if filters: conditions: List[bool] = [] for key, value in filters.items(): conditions.append(getattr(model, key) == value) query = query.filter(and_(*conditions)) - results: List[DeclarativeMeta] = query.all() - return jsonify(results), 200 + + if select_values: + query = query.with_entities(*[getattr(model, value) for value in select_values]) + query_result = query.all() + results = [] + for instance in query_result: + selected_instance = {} + for value in select_values: + selected_instance[value] = getattr(instance, value) + results.append(selected_instance) + else: + results = models_to_dict(query.all()) + if url_mapper: + results = map_all_keys_to_url(url_mapper, results) + response = {"data": results, + "message": "Resources fetched successfully", + "url": response_url} + return jsonify(response), 200 except SQLAlchemyError: - return {"error": "Something went wrong while querying the database."}, 500 + return {"error": "Something went wrong while querying the database.", + "url": response_url}, 500 def query_by_id_from_model(model: DeclarativeMeta, column_name: str, From e42c57ded2cbedd64217d31e0aa8808e0b5e4771 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:33:06 +0100 Subject: [PATCH 079/377] simplified endpoint functions by using query_agent functions --- .../project/endpoints/projects/projects.py | 78 ++++--------------- 1 file changed, 13 insertions(+), 65 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index f444e283..cb516b1a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -2,18 +2,14 @@ Module that implements the /projects endpoint of the API """ from os import getenv -from dotenv import load_dotenv -from flask import jsonify +from flask import request from flask_restful import Resource -from sqlalchemy import exc +from urllib.parse import urljoin - -from project import db from project.models.projects import Project -from project.endpoints.projects.endpoint_parser import parse_project_params +from project.utils.query_agent import query_selected_from_model, insert_into_model -load_dotenv() API_URL = getenv('API_HOST') class ProjectsEndpoint(Resource): @@ -28,67 +24,19 @@ def get(self): Get method for listing all available projects that are currently in the API """ - try: - projects = Project.query.with_entities( - Project.project_id, - Project.title, - Project.descriptions - ).all() - - results = [{ - "project_id": row[0], - "title": row[1], - "descriptions": row[2] - } for row in projects] - - # return all valid entries for a project and return a 200 OK code - return { - "data": results, - "url": f"{API_URL}/projects", - "message": "Projects fetched successfully" - }, 200 - except exc.SQLAlchemyError: - return { - "message": "Something unexpected happenend when trying to get the projects", - "url": f"{API_URL}/projects" - }, 500 + SUMMARY_FIELDS = ["project_id", "title", "descriptions"] + response_url = urljoin(API_URL, "/projects") + return query_selected_from_model(Project, + response_url, + select_values=SUMMARY_FIELDS, + url_mapper={"project_id": response_url}, + filters=request.args + ) def post(self): """ Post functionality for project using flask_restfull parse lib """ - args = parse_project_params() - - # create a new project object to add in the API later - new_project = Project( - title=args['title'], - descriptions=args['descriptions'], - assignment_file=args['assignment_file'], - deadline=args['deadline'], - course_id=args['course_id'], - visible_for_students=args['visible_for_students'], - archieved=args['archieved'], - test_path=args['test_path'], - script_name=args['script_name'], - regex_expressions=args['regex_expressions'] - ) - - # add the new project to the database and commit the changes - - try: - db.session.add(new_project) - db.session.commit() - new_project_json = jsonify(new_project).json - - return { - "url": f"{API_URL}/projects/{new_project_json['project_id']}", - "message": "Project posted successfully", - "data": new_project_json - }, 201 - except exc.SQLAlchemyError: - return ({ - "url": f"{API_URL}/projects", - "message": "Something unexpected happenend when trying to add a new project", - "data": jsonify(new_project).json - }, 500) + + return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) From 3467bb22b6213a2a81878e480a2248b53b949afa Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:41:07 +0100 Subject: [PATCH 080/377] fixed linting --- .../project/endpoints/projects/projects.py | 7 ++--- backend/project/utils/misc.py | 31 +++++++++++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index cb516b1a..638cbabc 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -2,10 +2,10 @@ Module that implements the /projects endpoint of the API """ from os import getenv +from urllib.parse import urljoin from flask import request from flask_restful import Resource -from urllib.parse import urljoin from project.models.projects import Project from project.utils.query_agent import query_selected_from_model, insert_into_model @@ -24,11 +24,10 @@ def get(self): Get method for listing all available projects that are currently in the API """ - SUMMARY_FIELDS = ["project_id", "title", "descriptions"] response_url = urljoin(API_URL, "/projects") return query_selected_from_model(Project, response_url, - select_values=SUMMARY_FIELDS, + select_values=["project_id", "title", "descriptions"], url_mapper={"project_id": response_url}, filters=request.args ) @@ -38,5 +37,5 @@ def post(self): Post functionality for project using flask_restfull parse lib """ - + return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index 3c348175..cd20a6f7 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -1,5 +1,12 @@ +""" +This module contains functions that are not related to anything specific but +are ought to be used throughout the project. +""" + from typing import Dict, List from urllib.parse import urljoin +from sqlalchemy.ext.declarative import DeclarativeMeta + def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[str, str]: """ @@ -31,8 +38,26 @@ def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): print(data) return [map_keys_to_url(url_mapper, entry) for entry in data] -def model_to_dict(instance): +def model_to_dict(instance: DeclarativeMeta) -> Dict[str, str]: + """ + Converts an sqlalchemy model to a dictionary. + + Args: + instance: DeclarativeMeta - The instance of the model to convert to a dictionary. + + Returns: + A dictionary with the keys and values of the model. + """ return {column.key: getattr(instance, column.key) for column in instance.__table__.columns} -def models_to_dict(instances): - return [model_to_dict(instance) for instance in instances] \ No newline at end of file +def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: + """ + Converts a list of sqlalchemy models to a list of dictionaries. + + Args: + instances: List[DeclarativeMeta] - The instances of the models to convert to dictionaries. + + Returns: + A list of dictionaries with the keys and values of the models. + """ + return [model_to_dict(instance) for instance in instances] From 69df26e9e720f9f1adb80024051937b6e36aefcc Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 15:55:16 +0100 Subject: [PATCH 081/377] fixed urljoin incorrectly joining url --- backend/project/endpoints/projects/projects.py | 16 +++++++++------- backend/project/utils/misc.py | 5 +++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 638cbabc..d273bfca 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -24,13 +24,15 @@ def get(self): Get method for listing all available projects that are currently in the API """ - response_url = urljoin(API_URL, "/projects") - return query_selected_from_model(Project, - response_url, - select_values=["project_id", "title", "descriptions"], - url_mapper={"project_id": response_url}, - filters=request.args - ) + + response_url = urljoin(API_URL, "projects") + return query_selected_from_model( + Project, + response_url, + select_values=["project_id", "title", "descriptions"], + url_mapper={"project_id": response_url}, + filters=request.args + ) def post(self): """ diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index cd20a6f7..2c82fbac 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -21,7 +21,9 @@ def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[st """ for key, value in data.items(): if key in url_mapper: - data[key] = urljoin(url_mapper[key], str(value)) + data[key] = urljoin(url_mapper[key] + "/", str(value)) + print(url_mapper) + print(data) return data def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): @@ -35,7 +37,6 @@ def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): Returns: A list of dictionaries with the keys mapped to the urls. """ - print(data) return [map_keys_to_url(url_mapper, entry) for entry in data] def model_to_dict(instance: DeclarativeMeta) -> Dict[str, str]: From e836f5037268a742b45877ed007cc3e5621b0e5d Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:02:57 +0100 Subject: [PATCH 082/377] lint: removed trailing whitepsace --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index d273bfca..68600034 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -24,7 +24,7 @@ def get(self): Get method for listing all available projects that are currently in the API """ - + response_url = urljoin(API_URL, "projects") return query_selected_from_model( Project, From e237c89292ac19ce281707e927520077d25ba3a2 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 17:07:59 +0100 Subject: [PATCH 083/377] rephrased the .env file part --- backend/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/README.md b/backend/README.md index 67982866..31801174 100644 --- a/backend/README.md +++ b/backend/README.md @@ -31,8 +31,8 @@ the regular base application. ``` ## Setting up the environment variables -The project requires a couple of environment variables to run, -these should be located in your own .env file if you want to develop on this codebase. +The project requires a couple of environment variables to run, if you want to develop on this codebase. +Setting values for these variables can be done with a method to your own liking, like a .env file or setting them in a Dockerfile. | Variable | Description | |-------------------|----------------------------------------------------------------| From 5bb6d320e8b48c2d23344259b3409f9128a4f346 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 17:10:19 +0100 Subject: [PATCH 084/377] rephrased more --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index 31801174..11139c3a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -32,7 +32,7 @@ the regular base application. ## Setting up the environment variables The project requires a couple of environment variables to run, if you want to develop on this codebase. -Setting values for these variables can be done with a method to your own liking, like a .env file or setting them in a Dockerfile. +Setting values for these variables can be done with a method to your own liking. | Variable | Description | |-------------------|----------------------------------------------------------------| From e2e4e39ae87b382d96de5b9e06e5f9471f4b859b Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 17:11:06 +0100 Subject: [PATCH 085/377] fixed connection part rephrasing --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index 11139c3a..0586ce1d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -44,7 +44,7 @@ Setting values for these variables can be done with a method to your own liking. | API_HOST | Location of the API root | All the variables except the last one are for the database setup, -these are needed or else you can't make a valid connection. +these are needed to make a connection with the database. The last one is for keeping the API restfull since the location of the recourse should be located. ## Running the project From caab904da5523693c85c27b01243ed396fde0e32 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 17:12:04 +0100 Subject: [PATCH 086/377] fixed localhost --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index 0586ce1d..f3bfa49d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -53,7 +53,7 @@ navigating to the backend directory and running: ```sh python project ``` -The server should now be located at localhost:5000 and you can +The server should now be located at `localhost:5000` and you can start developping. ## Maintaining the codebase From e3fb7b81d70154c794ea975b23c1a33f445ac347 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 17:12:27 +0100 Subject: [PATCH 087/377] removed parentheses --- backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/README.md b/backend/README.md index f3bfa49d..7f35ef43 100644 --- a/backend/README.md +++ b/backend/README.md @@ -64,7 +64,7 @@ writing tests is mandatory for this, the test library used in this codebase is [ If you want to write tests we highly advise to read the pytest documentation on how to write tests, so they are kept conventional. -For executing the tests and testing you're newly added functionality (and to test if you broke nothing from earlier working code) +For executing the tests and testing you're newly added functionality you can run: ```sh sudo ./run_tests.sh From 401f58642fb51ebf01bb3a52959831fefe3441df Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 6 Mar 2024 17:13:41 +0100 Subject: [PATCH 088/377] fixed requirment.txt links --- backend/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/README.md b/backend/README.md index 7f35ef43..abfa6401 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,9 +1,9 @@ # Project pigeonhole backend ## Prerequisites -If you want the development environment run both commands if you only need to deploy only run the Deploment command. +If you want the development environment run both commands if you only need to deploy only run the Deployment command. -The dev-requirements.txt contains everything for writing tests and linters for maintaining quality code. -On the other hand the regular requirements.txt install the packages needed for +The [dev-requirements.txt](dev-requirements.txt) contains everything for writing tests and linters for maintaining quality code. +On the other hand the regular [requirements.txt](requirements.txt) install the packages needed for the regular base application. - Deployment From e26c015bdb5a3080c0615690d5138a442cb61fb0 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:41:17 +0100 Subject: [PATCH 089/377] completely replaced functionality with query_agent functions --- .../endpoints/projects/project_detail.py | 97 +++++-------------- .../project/endpoints/projects/projects.py | 5 +- 2 files changed, 27 insertions(+), 75 deletions(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 5428dcac..f2ce1a00 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -4,16 +4,18 @@ the corresponding project is 1 """ from os import getenv +from urllib.parse import urljoin -from flask import jsonify -from flask_restful import Resource, abort -from sqlalchemy import exc -from project.endpoints.projects.endpoint_parser import parse_project_params +from flask import request +from flask_restful import Resource from project import db from project.models.projects import Project +from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, patch_by_id_from_model + API_URL = getenv('API_HOST') +RESPONSE_URL = urljoin(API_URL, "projects") class ProjectDetail(Resource): """ @@ -22,14 +24,6 @@ class ProjectDetail(Resource): for implementing get, delete and put methods """ - def abort_if_not_present(self, project): - """ - Check if the project exists in the database - and if not abort the request and give back a 404 not found - """ - if project is None: - abort(404) - def get(self, project_id): """ Get method for listing a specific project @@ -37,22 +31,11 @@ def get(self, project_id): the id fetched from the url with the reaparse """ - try: - # fetch the project with the id that is specified in the url - project = Project.query.filter_by(project_id=project_id).first() - self.abort_if_not_present(project) - - # return the fetched project and return 200 OK status - return { - "data": jsonify(project).json, - "url": f"{API_URL}/projects/{project_id}", - "message": "Got project successfully" - }, 200 - except exc.SQLAlchemyError: - return { - "message": "Internal server error", - "url": f"{API_URL}/projects/{project_id}" - }, 500 + return query_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL) def patch(self, project_id): """ @@ -60,30 +43,13 @@ def patch(self, project_id): filtered by id of that specific project """ - # get the project that need to be edited - project = Project.query.filter_by(project_id=project_id).first() - - # check which values are not None in the dict - # if it is not None it needs to be modified in the database - - # commit the changes and return the 200 OK code if it succeeds, else 500 - try: - var_dict = parse_project_params() - for key, value in var_dict.items(): - setattr(project, key, value) - db.session.commit() - # get the updated version - return { - "message": f"Succesfully changed project with id: {id}", - "url": f"{API_URL}/projects/{id}", - "data": project - }, 200 - except exc.SQLAlchemyError: - db.session.rollback() - return { - "message": f"Something unexpected happenend when trying to edit project {id}", - "url": f"{API_URL}/projects/{id}" - }, 500 + return patch_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL, + request.json + ) def delete(self, project_id): """ @@ -91,25 +57,8 @@ def delete(self, project_id): done by project id """ - # fetch the project that needs to be removed - deleted_project = Project.query.filter_by(project_id=project_id).first() - - # check if its an existing one - self.abort_if_not_present(deleted_project) - - # if it exists delete it and commit the changes in the database - try: - db.session.delete(deleted_project) - db.session.commit() - - # return 200 if content is deleted succesfully - return { - "message": f"Project with id: {id} deleted successfully", - "url": f"{API_URL}/projects/{id} deleted successfully!", - "data": deleted_project - }, 200 - except exc.SQLAlchemyError: - return { - "message": f"Something unexpected happened when removing project {project_id}", - "url": f"{API_URL}/projects/{id}" - }, 500 + return delete_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 68600034..0834988f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -40,4 +40,7 @@ def post(self): using flask_restfull parse lib """ - return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) + return insert_into_model( + Project,request.json, + urljoin(API_URL, "/projects"), + "project_id") From fd941b2b33d3cc44d02324ced058a459587a8476 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:41:35 +0100 Subject: [PATCH 090/377] added functionality for patching an entry in the database --- backend/project/utils/query_agent.py | 69 +++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 9b348350..3837820f 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -14,7 +14,7 @@ from project.db_in import db from project.utils.misc import map_all_keys_to_url, models_to_dict -def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int): +def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int, base_url: str): """ Deletes an entry from the database giving the model corresponding to a certain table, a column name and its value. @@ -35,16 +35,21 @@ def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: ).first() if not result: - return {"message": "Resource not found"}, 404 + return { + "message": "Resource not found", + "url": base_url}, 404 db.session.delete(result) db.session.commit() - return {"message": "Resource deleted successfully"}, 200 + return {"message": "Resource deleted successfully", + "url": base_url}, 200 except SQLAlchemyError: - return {"error": "Something went wrong while deleting from the database."}, 500 + return {"error": "Something went wrong while deleting from the database.", + "url": base_url}, 500 def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], - response_url_base: str): + response_url_base: str, + url_id_field: str): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -60,11 +65,13 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance: DeclarativeMeta = model(**data) + db.session.add(new_instance) db.session.commit() - return {"data": new_instance, + return jsonify({ + "data": new_instance, "message": "Object created succesfully.", - "url": urljoin(response_url_base, str(new_instance.project_id))}, 201 + "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 except SQLAlchemyError: return {"error": "Something went wrong while inserting into the database.", "url": response_url_base}, 500 @@ -122,7 +129,7 @@ def query_selected_from_model(model: DeclarativeMeta, def query_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int, - not_found_message: str="Resource not found"): + base_url: str): """ Query an entry from the database giving the model corresponding to a certain table, a column name and its value. @@ -141,7 +148,47 @@ def query_by_id_from_model(model: DeclarativeMeta, try: result: Query = model.query.filter(getattr(model, column_name) == column_id).first() if not result: - return {"message": not_found_message}, 404 - return jsonify(result), 200 + return {"message": "Resource not found", "url": base_url}, 404 + print(column_id) + return jsonify({ + "data": result, + "message": "Resource fetched correctly", + "url": urljoin(base_url + "/", str(column_id))}), 200 except SQLAlchemyError: - return {"error": "Something went wrong while querying the database."}, 500 + return { + "error": "Something went wrong while querying the database.", + "url": base_url}, 500 + +def patch_by_id_from_model(model: DeclarativeMeta, + column_name: str, + column_id: int, + base_url: str, + data: Dict[str, Union[str, int]]): + """ + Update an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to update. + column_name: str - The name of the column to update. + id: int - The id of the entry to update. + data: Dict[str, Union[str, int]] - The data to update the entry with. + + Returns: + The entry updated from the database if the operation was successful, otherwise + a message indicating that something went wrong while updating the entry. + """ + try: + result: Query = model.query.filter(getattr(model, column_name) == column_id).first() + if not result: + return {"message": "Resource not found", "url": base_url}, 404 + for key, value in data.items(): + setattr(result, key, value) + db.session.commit() + return jsonify({ + "data": result, + "message": "Resource updated successfully", + "url": urljoin(base_url + "/", str(column_id))}), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while updating the database.", + "url": base_url}, 500 From 073d6c57c329f9a40a31fd7583a6a5656803baeb Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 17:46:55 +0100 Subject: [PATCH 091/377] fixed linting --- backend/project/endpoints/projects/project_detail.py | 4 ++-- backend/project/utils/query_agent.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index f2ce1a00..e2314bd9 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -9,9 +9,9 @@ from flask import request from flask_restful import Resource -from project import db from project.models.projects import Project -from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, patch_by_id_from_model +from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ + patch_by_id_from_model API_URL = getenv('API_HOST') diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 3837820f..c4053df4 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -14,7 +14,11 @@ from project.db_in import db from project.utils.misc import map_all_keys_to_url, models_to_dict -def delete_by_id_from_model(model: DeclarativeMeta, column_name: str, column_id: int, base_url: str): +def delete_by_id_from_model( + model: DeclarativeMeta, + column_name: str, + column_id: int, + base_url: str): """ Deletes an entry from the database giving the model corresponding to a certain table, a column name and its value. @@ -65,7 +69,7 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance: DeclarativeMeta = model(**data) - + db.session.add(new_instance) db.session.commit() return jsonify({ From fd2ae8379e63c91d15526d758323a5a00f3b8ea9 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:16:22 +0100 Subject: [PATCH 092/377] filtered queries and forms to only contain entries that are valid in table --- backend/project/utils/query_agent.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index c4053df4..d0595afd 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -7,12 +7,12 @@ from typing import Dict, List, Union from urllib.parse import urljoin from flask import jsonify -from sqlalchemy import and_ +from sqlalchemy import and_, inspect from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError from project.db_in import db -from project.utils.misc import map_all_keys_to_url, models_to_dict +from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields def delete_by_id_from_model( model: DeclarativeMeta, @@ -53,7 +53,8 @@ def delete_by_id_from_model( def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, - url_id_field: str): + url_id_field: str, + required_fields: List[str] = []): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -68,17 +69,24 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - new_instance: DeclarativeMeta = model(**data) - + # Check if all non-nullable fields are present in the data + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + return {"error": f"Missing required fields: {', '.join(missing_fields)}", + "url": response_url_base}, 400 + + filtered_data = filter_model_fields(model, data) + new_instance: DeclarativeMeta = model(**filtered_data) db.session.add(new_instance) db.session.commit() return jsonify({ "data": new_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError: - return {"error": "Something went wrong while inserting into the database.", - "url": response_url_base}, 500 + except SQLAlchemyError as e: + return jsonify({"error": "Something went wrong while inserting into the database.", + "url": response_url_base}), 500 def query_selected_from_model(model: DeclarativeMeta, response_url: str, @@ -104,8 +112,9 @@ def query_selected_from_model(model: DeclarativeMeta, try: query: Query = model.query if filters: + filtered_filters = filter_model_fields(model, filters) conditions: List[bool] = [] - for key, value in filters.items(): + for key, value in filtered_filters.items(): conditions.append(getattr(model, key) == value) query = query.filter(and_(*conditions)) From d675fe69348b9e2fa4c682518a96677cd24eb36f Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:17:57 +0100 Subject: [PATCH 093/377] created function that filters dict keys that are not in table --- backend/project/utils/misc.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index 2c82fbac..9d313467 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -62,3 +62,7 @@ def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: A list of dictionaries with the keys and values of the models. """ return [model_to_dict(instance) for instance in instances] + + +def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): + return {key: value for key, value in data.items() if hasattr(model, key)} \ No newline at end of file From 2298d8c2536a19b4c9f9035cadb26508a143561a Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:19:31 +0100 Subject: [PATCH 094/377] made class serializable --- backend/project/models/courses.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index dc778706..8d3f0651 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,14 +1,16 @@ """The Course model""" +from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String from project import db +@dataclass class Course(db.Model): """This class described the courses table, a course has an id, name, optional ufora id and the teacher that created it""" __tablename__ = "courses" - course_id = Column(Integer, primary_key=True) - name = Column(String(50), nullable=False) - ufora_id = Column(String(50), nullable=True) - teacher = Column(String(255), ForeignKey("users.uid"), nullable=False) + course_id: int = Column(Integer, primary_key=True) + name: str = Column(String(50), nullable=False) + ufora_id: str = Column(String(50), nullable=True) + teacher: str = Column(String(255), ForeignKey("users.uid"), nullable=False) From d0e9a10bcc23b38b7ceff44df02a942bbdb910d9 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 19:20:01 +0100 Subject: [PATCH 095/377] url query is not a valid authentication method, filtered out option --- backend/tests/endpoints/courses_test.py | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 4df98cd5..9beb64fe 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -25,19 +25,6 @@ def test_post_courses(self, courses_init_db, client, course_data, invalid_course assert course is not None assert course.teacher == "Bart" - response = client.post( - "/courses?uid=Jef", json=course_data - ) # non existent user - assert response.status_code == 404 - - response = client.post( - "/courses?uid=student_sel2_0", json=course_data - ) # existent user but no rights - assert response.status_code == 403 - - response = client.post("/courses", json=course_data) # bad link, no uid passed - assert response.status_code == 400 - response = client.post( "/courses?uid=Bart", json=invalid_course ) # invalid course @@ -87,12 +74,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client assert response.status_code == 400 sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" - - response = client.post( - sel2_admins_link + "?uid=student_sel2_0", # unauthorized user - json={"admin_uid": "Rien"}, - ) - assert response.status_code == 403 + course_admins = [ s.uid for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() From 6b3e73300e6aaa82a82671b66f08c52acb6918f5 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:17:33 +0100 Subject: [PATCH 096/377] using query_agent functions to prevent code duplication --- backend/project/endpoints/courses.py | 256 ++++++++++----------------- 1 file changed, 89 insertions(+), 167 deletions(-) diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py index a09e7cfb..4954eea0 100644 --- a/backend/project/endpoints/courses.py +++ b/backend/project/endpoints/courses.py @@ -1,6 +1,10 @@ """Course api point""" from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + from flask import Blueprint, jsonify, request from flask import abort from flask_restful import Api, Resource @@ -8,14 +12,17 @@ from project.models.course_relations import CourseAdmin, CourseStudent from project.models.users import User from project.models.courses import Course -from project.models.projects import Project +from project.utils.query_agent import query_selected_from_model, \ + insert_into_model,delete_by_id_from_model, \ + patch_by_id_from_model from project import db courses_bp = Blueprint("courses", __name__) courses_api = Api(courses_bp) +load_dotenv() API_URL = getenv("API_HOST") - +RESPONSE_URL = urljoin(API_URL + "/", "courses") def execute_query_abort_if_db_error(query, url, query_all=False): """ @@ -231,7 +238,6 @@ def get_course_abort_if_not_found(course_id): return course - class CourseForUser(Resource): """Api endpoint for the /courses link""" @@ -241,72 +247,27 @@ def get(self): to get all courses and filter by given query parameter like /courses?parameter=... parameters can be either one of the following: teacher,ufora_id,name. """ - query = Course.query - if "teacher" in request.args: - query = query.filter_by(course_id=request.args.get("teacher")) - if "ufora_id" in request.args: - query = query.filter_by(ufora_id=request.args.get("ufora_id")) - if "name" in request.args: - query = query.filter_by(name=request.args.get("name")) - results = execute_query_abort_if_db_error( - query, url=API_URL + "/courses", query_all=True + + return query_selected_from_model( + Course, + RESPONSE_URL, + url_mapper={"course_id": RESPONSE_URL}, + filters=request.args ) - detail_urls = [ - f"{API_URL}/courses/{str(course.course_id)}" for course in results - ] - message = "Succesfully retrieved all courses with given parameters" - response = json_message(message) - response["data"] = detail_urls - response["url"] = API_URL + "/courses" - return response def post(self): """ This function will create a new course if the body of the post contains a name and uid is an admin or teacher """ - abort_url = API_URL + "/courses" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - user = abort_if_no_user_found_for_uid(uid, abort_url) - - if not user.is_teacher: - message = ( - "Only teachers or admins can create new courses, you are unauthorized" - ) - return json_message(message), 403 - - data = request.get_json() - - if "name" not in data: - message = "Missing 'name' in the request body" - return json_message(message), 400 - - name = data["name"] - new_course = Course(name=name, teacher=uid) - if "ufora_id" in data: - new_course.ufora_id = data["ufora_id"] - add_abort_if_error(new_course, abort_url) - commit_abort_if_error(abort_url) - - admin_course = CourseAdmin(uid=uid, course_id=new_course.course_id) - add_abort_if_error(admin_course, abort_url) - commit_abort_if_error(abort_url) - - message = (f"Course with name: {name} and" - f"course_id:{new_course.course_id} was succesfully created") - response = json_message(message) - data = { - "course_id": API_URL + "/courses/" + str(new_course.course_id), - "name": new_course.name, - "teacher": API_URL + "/users/" + new_course.teacher, - "ufora_id": new_course.ufora_id if new_course.ufora_id else "None", - } - response["data"] = data - response["url"] = API_URL + "/courses/" + str(new_course.course_id) - return response, 201 + return insert_into_model( + Course, + request.json, + RESPONSE_URL, + "course_id", + required_fields=["name", "teacher"] + ) class CourseByCourseId(Resource): @@ -324,119 +285,76 @@ def get(self, course_id): ] } """ - abort_url = API_URL + "/courses" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - admin = get_admin_relation(uid, course_id) - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student = execute_query_abort_if_db_error(query, abort_url) - - if not (admin or student): - message = "User is not an admin, nor a student of this course" - return json_message(message), 404 - - course = get_course_abort_if_not_found(course_id) - query = Project.query.filter_by(course_id=course_id) - abort_url = API_URL + "/courses/" + str(course_id) - # course does exist so url should be to the id - project_uids = [ - API_URL + "/projects/" + project.project_id - for project in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + admin.uid - for admin in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + student.uid - for student in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - - data = { - "ufora_id": course.ufora_id, - "teacher": API_URL + "/users/" + course.teacher, - "admins": admin_uids, - "students": student_uids, - "projects": project_uids, - } - response = json_message( - "Succesfully retrieved course with course_id: " + str(course_id) - ) - response["data"] = data - response["url"] = API_URL + "/courses/" + str(course_id) - return response + try: + course_details = db.session.query( + Course.course_id, + Course.name, + Course.ufora_id, + Course.teacher + ).filter( + Course.course_id == course_id).first() + + if not course_details: + return { + "message": "Course not found", + "url": RESPONSE_URL + }, 404 + + admins = db.session.query(CourseAdmin.uid).filter( + CourseAdmin.course_id == course_id + ).all() + + students = db.session.query(CourseStudent.uid).filter( + CourseStudent.course_id == course_id + ).all() + + user_url = urljoin(API_URL + "/", "users") + + admin_ids = [ urljoin(user_url + "/" , admin[0]) for admin in admins] + student_ids = [ urljoin(user_url + "/", student[0]) for student in students] + + result = { + 'course_id': course_details.course_id, + 'name': course_details.name, + 'ufora_id': course_details.ufora_id, + 'teacher': course_details.teacher, + 'admins': admin_ids, + 'students': student_ids + } + + return { + "message": "Succesfully retrieved course with course_id: " + str(course_id), + "data": result, + "url": urljoin(RESPONSE_URL + "/", str(course_id)) + } + except SQLAlchemyError: + return { + "error": "Something went wrong while querying the database.", + "url": RESPONSE_URL}, 500 def delete(self, course_id): """ This function will delete the course with course_id """ - abort_url = API_URL + "/courses/" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - admin = get_admin_relation(uid, course_id) - - if not admin: - message = "You are not an admin of this course and so you cannot delete it" - return json_message(message), 403 - - course = get_course_abort_if_not_found(course_id) - abort_url = API_URL + "/courses/" + str(course_id) - # course does exist so url should be to the id - delete_abort_if_error(course, abort_url) - commit_abort_if_error(abort_url) - - response = { - "message": "Succesfully deleted course with course_id: " + str(course_id), - "url": API_URL + "/courses", - } - return response + return delete_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL + ) def patch(self, course_id): """ This function will update the course with course_id """ - abort_url = API_URL + "/courses/" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - admin = get_admin_relation(uid, course_id) - - if not admin: - message = "You are not an admin of this course and so you cannot update it" - return json_message(message), 403 - data = request.get_json() - course = get_course_abort_if_not_found(course_id) - abort_url = API_URL + "/courses/" + str(course_id) - if "name" in data: - course.name = data["name"] - if "teacher" in data: - course.teacher = data["teacher"] - if "ufora_id" in data: - course.ufora_id = data["ufora_id"] - - commit_abort_if_error(abort_url) - response = json_message( - "Succesfully updated course with course_id: " + str(course_id) + return patch_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL, + request.json ) - response["url"] = API_URL + "/courses/" + str(course_id) - data = { - "course_id": API_URL + "/courses/" + str(course.course_id), - "name": course.name, - "teacher": API_URL + "/users/" + course.teacher, - "ufora_id": course.ufora_id if course.ufora_id else "None", - } - response["data"] = data - return response, 200 class CourseForAdmins(Resource): @@ -461,7 +379,7 @@ def get(self, course_id): "Succesfully retrieved all admins of course " + str(course_id) ) response["data"] = admin_uids - response["url"] = abort_url # not actually aborting here tho heheh + response["url"] = abort_url return jsonify(admin_uids) def post(self, course_id): @@ -608,10 +526,14 @@ def delete(self, course_id): return response -courses_api.add_resource(CourseForUser, "/courses") +courses_bp.add_url_rule("/courses", + view_func=CourseForUser.as_view('course_endpoint')) -courses_api.add_resource(CourseByCourseId, "/courses/") +courses_bp.add_url_rule("/courses/", + view_func=CourseByCourseId.as_view('course_by_course_id')) -courses_api.add_resource(CourseForAdmins, "/courses//admins") +courses_bp.add_url_rule("/courses//admins", + view_func=CourseForAdmins.as_view('course_admins')) -courses_api.add_resource(CourseToAddStudents, "/courses//students") +courses_bp.add_url_rule("/courses//students", + view_func=CourseToAddStudents.as_view('course_students')) From a00cb993fa4a32dd9ed4ae2d9805f1dfae848980 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:55:17 +0100 Subject: [PATCH 097/377] split courses into multiple files to keep it organized --- backend/project/endpoints/courses.py | 539 ------------------ .../courses/course_admin_relation.py | 111 ++++ .../endpoints/courses/course_details.py | 111 ++++ .../courses/course_student_relation.py | 113 ++++ backend/project/endpoints/courses/courses.py | 51 ++ .../endpoints/courses/courses_config.py | 32 ++ .../endpoints/courses/courses_utils.py | 234 ++++++++ 7 files changed, 652 insertions(+), 539 deletions(-) delete mode 100644 backend/project/endpoints/courses.py create mode 100644 backend/project/endpoints/courses/course_admin_relation.py create mode 100644 backend/project/endpoints/courses/course_details.py create mode 100644 backend/project/endpoints/courses/course_student_relation.py create mode 100644 backend/project/endpoints/courses/courses.py create mode 100644 backend/project/endpoints/courses/courses_config.py create mode 100644 backend/project/endpoints/courses/courses_utils.py diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py deleted file mode 100644 index 4954eea0..00000000 --- a/backend/project/endpoints/courses.py +++ /dev/null @@ -1,539 +0,0 @@ -"""Course api point""" - -from os import getenv -from urllib.parse import urljoin - -from dotenv import load_dotenv - -from flask import Blueprint, jsonify, request -from flask import abort -from flask_restful import Api, Resource -from sqlalchemy.exc import SQLAlchemyError -from project.models.course_relations import CourseAdmin, CourseStudent -from project.models.users import User -from project.models.courses import Course -from project.utils.query_agent import query_selected_from_model, \ - insert_into_model,delete_by_id_from_model, \ - patch_by_id_from_model -from project import db - -courses_bp = Blueprint("courses", __name__) -courses_api = Api(courses_bp) - -load_dotenv() -API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") - -def execute_query_abort_if_db_error(query, url, query_all=False): - """ - Execute the given SQLAlchemy query and handle any SQLAlchemyError that might occur. - If query_all == True, the query will be executed with the all() method, - otherwise with the first() method. - Args: - query (Query): The SQLAlchemy query to execute. - - Returns: - ResultProxy: The result of the query if successful, otherwise aborts with error 500. - """ - try: - if query_all: - result = query.all() - else: - result = query.first() - except SQLAlchemyError as e: - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - return result - - -def add_abort_if_error(to_add, url): - """ - Add a new object to the database - and handle any SQLAlchemyError that might occur. - - Args: - to_add (object): The object to add to the database. - """ - try: - db.session.add(to_add) - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def delete_abort_if_error(to_delete, url): - """ - Deletes the given object from the database - and aborts the request with a 500 error if a SQLAlchemyError occurs. - - Args: - - to_delete: The object to be deleted from the database. - """ - try: - db.session.delete(to_delete) - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def commit_abort_if_error(url): - """ - Commit the current session and handle any SQLAlchemyError that might occur. - """ - try: - db.session.commit() - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): - """ - Check if the current user is authorized to appoint new admins to a course. - - Args: - course_id (int): The ID of the course. - - Raises: - HTTPException: If the current user is not authorized or - if the UID of the person to be made an admin is missing in the request body. - """ - url = API_URL + "/courses/" + str(course_id) + "/admins" - abort_if_uid_is_none(teacher, url) - - course = get_course_abort_if_not_found(course_id) - - if teacher != course.teacher: - response = json_message("Only the teacher of a course can appoint new admins") - response["url"] = url - abort(403, description=response) - - if not assistant: - response = json_message( - "uid of person to make admin is required in the request body" - ) - response["url"] = url - abort(400, description=response) - - -def abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids -): - """ - Check the request to assign new students to a course. - - Args: - course_id (int): The ID of the course. - - Raises: - 403: If the user is not authorized to assign new students to the course. - 400: If the request body does not contain the required 'students' field. - """ - url = API_URL + "/courses/" + str(course_id) + "/students" - get_course_abort_if_not_found(course_id) - abort_if_no_user_found_for_uid(uid, url) - query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) - admin_relation = execute_query_abort_if_db_error(query, url) - if not admin_relation: - message = "Not authorized to assign new students to course with id " + str( - course_id - ) - response = json_message(message) - response["url"] = url - abort(403, description=response) - - if not student_uids: - message = """To assign new students to a course, - you should have a students field with a list of uids in the request body""" - response = json_message(message) - response["url"] = url - abort(400, description=response) - - -def abort_if_uid_is_none(uid, url): - """ - Check whether the uid is None if so - abort with error 400 - """ - if uid is None: - response = json_message("There should be a uid in the request query") - response["url"] = url - abort(400, description=response) - - -def abort_if_no_user_found_for_uid(uid, url): - """ - Check if a user exists based on the provided uid. - - Args: - uid (int): The unique identifier of the user. - - Raises: - NotFound: If the user with the given uid is not found. - """ - query = User.query.filter_by(uid=uid) - user = execute_query_abort_if_db_error(query, url) - - if not user: - response = json_message("User with uid " + uid + " was not found") - response["url"] = url - abort(404, description=response) - return user - - -def get_admin_relation(uid, course_id): - """ - Retrieve the CourseAdmin object for the given uid and course. - - Args: - uid (int): The user ID. - course_id (int): The course ID. - - Returns: - CourseAdmin: The CourseAdmin object if the user is an admin, otherwise None. - """ - return execute_query_abort_if_db_error( - CourseAdmin.query.filter_by(uid=uid, course_id=course_id), - url=API_URL + "/courses/" + str(course_id) + "/admins", - ) - - -def json_message(message): - """ - Create a json message with the given message. - - Args: - message (str): The message to include in the json. - - Returns: - dict: The message in a json format. - """ - return {"message": message} - - -def get_course_abort_if_not_found(course_id): - """ - Get a course by its ID. - - Args: - course_id (int): The course ID. - - Returns: - Course: The course with the given ID. - """ - query = Course.query.filter_by(course_id=course_id) - course = execute_query_abort_if_db_error(query, API_URL + "/courses") - - if not course: - response = json_message("Course not found") - response["url"] = API_URL + "/courses" - abort(404, description=response) - - return course - -class CourseForUser(Resource): - """Api endpoint for the /courses link""" - - def get(self): - """ " - Get function for /courses this will be the main endpoint - to get all courses and filter by given query parameter like /courses?parameter=... - parameters can be either one of the following: teacher,ufora_id,name. - """ - - return query_selected_from_model( - Course, - RESPONSE_URL, - url_mapper={"course_id": RESPONSE_URL}, - filters=request.args - ) - - def post(self): - """ - This function will create a new course - if the body of the post contains a name and uid is an admin or teacher - """ - - return insert_into_model( - Course, - request.json, - RESPONSE_URL, - "course_id", - required_fields=["name", "teacher"] - ) - - -class CourseByCourseId(Resource): - """Api endpoint for the /courses/course_id link""" - - def get(self, course_id): - """ - This get function will return all the related projects of the course - in the following form: - { - course: course with course_id - projects: [ - list of all projects that have course_id - where projects are jsons containing the title, deadline and project_id - ] - } - """ - try: - course_details = db.session.query( - Course.course_id, - Course.name, - Course.ufora_id, - Course.teacher - ).filter( - Course.course_id == course_id).first() - - if not course_details: - return { - "message": "Course not found", - "url": RESPONSE_URL - }, 404 - - admins = db.session.query(CourseAdmin.uid).filter( - CourseAdmin.course_id == course_id - ).all() - - students = db.session.query(CourseStudent.uid).filter( - CourseStudent.course_id == course_id - ).all() - - user_url = urljoin(API_URL + "/", "users") - - admin_ids = [ urljoin(user_url + "/" , admin[0]) for admin in admins] - student_ids = [ urljoin(user_url + "/", student[0]) for student in students] - - result = { - 'course_id': course_details.course_id, - 'name': course_details.name, - 'ufora_id': course_details.ufora_id, - 'teacher': course_details.teacher, - 'admins': admin_ids, - 'students': student_ids - } - - return { - "message": "Succesfully retrieved course with course_id: " + str(course_id), - "data": result, - "url": urljoin(RESPONSE_URL + "/", str(course_id)) - } - except SQLAlchemyError: - return { - "error": "Something went wrong while querying the database.", - "url": RESPONSE_URL}, 500 - - def delete(self, course_id): - """ - This function will delete the course with course_id - """ - return delete_by_id_from_model( - Course, - "course_id", - course_id, - RESPONSE_URL - ) - - def patch(self, course_id): - """ - This function will update the course with course_id - """ - - return patch_by_id_from_model( - Course, - "course_id", - course_id, - RESPONSE_URL, - request.json - ) - - -class CourseForAdmins(Resource): - """ - This class will handle post and delete queries to - the /courses/course_id/admins url, only the teacher of a course can do this - """ - - def get(self, course_id): - """ - This function will return all the admins of a course - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - get_course_abort_if_not_found(course_id) - - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + a.uid - for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all admins of course " + str(course_id) - ) - response["data"] = admin_uids - response["url"] = abort_url - return jsonify(admin_uids) - - def post(self, course_id): - """ - Api endpoint for adding new admins to a course, can only be done by the teacher - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - teacher = request.args.get("uid") - data = request.get_json() - assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - - query = User.query.filter_by(uid=assistant) - new_admin = execute_query_abort_if_db_error(query, abort_url) - if not new_admin: - message = ( - "User to make admin was not found, please request with a valid uid" - ) - return json_message(message), 404 - - admin_relation = CourseAdmin(uid=assistant, course_id=course_id) - add_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - response = json_message( - f"Admin assistant added to course {course_id}" - ) - response["url"] = abort_url - data = { - "course_id": API_URL + "/courses/" + str(course_id), - "uid": API_URL + "/users/" + assistant, - } - response["data"] = data - return response, 201 - - def delete(self, course_id): - """ - Api endpoint for removing admins of a course, can only be done by the teacher - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - teacher = request.args.get("uid") - data = request.get_json() - assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - - query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) - admin_relation = execute_query_abort_if_db_error(query, abort_url) - if not admin_relation: - message = "Course with given admin not found" - return json_message(message), 404 - - delete_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - - message = ( - f"Admin {assistant}" - f" was succesfully removed from course {course_id}" - ) - response = json_message(message) - response["url"] = abort_url - return response, 204 - - -class CourseToAddStudents(Resource): - """ - Class that will respond to the /courses/course_id/students link - teachers should be able to assign and remove students from courses, - and everyone should be able to list all students assigned to a course - """ - - def get(self, course_id): - """ - Get function at /courses/course_id/students - to get all the users assigned to a course - everyone can get this data so no need to have uid query in the link - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - get_course_abort_if_not_found(course_id) - - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + s.uid - for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all students of course " + str(course_id) - ) - response["data"] = student_uids - response["url"] = abort_url - return response - - def post(self, course_id): - """ - Allows admins of a course to assign new students by posting to: - /courses/course_id/students with a list of uid in the request body under key "students" - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - uid = request.args.get("uid") - data = request.get_json() - student_uids = data.get("students") - abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids - ) - - for uid in student_uids: - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student_relation = execute_query_abort_if_db_error(query, abort_url) - if student_relation: - db.session.rollback() - message = ( - "Student with uid " + uid + " is already assigned to the course" - ) - return json_message(message), 400 - add_abort_if_error(CourseStudent(uid=uid, course_id=course_id), abort_url) - commit_abort_if_error(abort_url) - response = json_message("User were succesfully added to the course") - response["url"] = abort_url - data = {"students": [API_URL + "/users/" + uid for uid in student_uids]} - response["data"] = data - return response, 201 - - def delete(self, course_id): - """ - This function allows admins of a course to remove students by sending a delete request to - /courses/course_id/students with inside the request body - a field "students" = [list of uids to unassign] - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - uid = request.args.get("uid") - data = request.get_json() - student_uids = data.get("students") - abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids - ) - - for uid in student_uids: - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student_relation = execute_query_abort_if_db_error(query, abort_url) - if student_relation: - delete_abort_if_error(student_relation, abort_url) - commit_abort_if_error(abort_url) - - response = json_message("User were succesfully removed from the course") - response["url"] = API_URL + "/courses/" + str(course_id) + "/students" - return response - - -courses_bp.add_url_rule("/courses", - view_func=CourseForUser.as_view('course_endpoint')) - -courses_bp.add_url_rule("/courses/", - view_func=CourseByCourseId.as_view('course_by_course_id')) - -courses_bp.add_url_rule("/courses//admins", - view_func=CourseForAdmins.as_view('course_admins')) - -courses_bp.add_url_rule("/courses//students", - view_func=CourseToAddStudents.as_view('course_students')) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py new file mode 100644 index 00000000..e4bc4b4e --- /dev/null +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -0,0 +1,111 @@ +""" +This module will handle the /courses//admins endpoint +It will allow the teacher of a course to add and remove admins from a course +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask import jsonify, request +from flask_restful import Resource + +from project.models.course_relations import CourseAdmin +from project.models.users import User +from project.endpoints.courses.courses_utils import ( + execute_query_abort_if_db_error, + add_abort_if_error, + commit_abort_if_error, + delete_abort_if_error, + get_course_abort_if_not_found, + abort_if_not_teacher_or_none_assistant, + json_message +) + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseForAdmins(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/admins url, only the teacher of a course can do this + """ + + def get(self, course_id): + """ + This function will return all the admins of a course + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + get_course_abort_if_not_found(course_id) + + query = CourseAdmin.query.filter_by(course_id=course_id) + admin_uids = [ + API_URL + "/users/" + a.uid + for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) + ] + response = json_message( + "Succesfully retrieved all admins of course " + str(course_id) + ) + response["data"] = admin_uids + response["url"] = abort_url + return jsonify(admin_uids) + + def post(self, course_id): + """ + Api endpoint for adding new admins to a course, can only be done by the teacher + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = User.query.filter_by(uid=assistant) + new_admin = execute_query_abort_if_db_error(query, abort_url) + if not new_admin: + message = ( + "User to make admin was not found, please request with a valid uid" + ) + return json_message(message), 404 + + admin_relation = CourseAdmin(uid=assistant, course_id=course_id) + add_abort_if_error(admin_relation, abort_url) + commit_abort_if_error(abort_url) + response = json_message( + f"Admin assistant added to course {course_id}" + ) + response["url"] = abort_url + data = { + "course_id": API_URL + "/courses/" + str(course_id), + "uid": API_URL + "/users/" + assistant, + } + response["data"] = data + return response, 201 + + def delete(self, course_id): + """ + Api endpoint for removing admins of a course, can only be done by the teacher + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/admins" + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, abort_url) + if not admin_relation: + message = "Course with given admin not found" + return json_message(message), 404 + + delete_abort_if_error(admin_relation, abort_url) + commit_abort_if_error(abort_url) + + message = ( + f"Admin {assistant}" + f" was succesfully removed from course {course_id}" + ) + response = json_message(message) + response["url"] = abort_url + return response, 204 diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py new file mode 100644 index 00000000..d7ac7c37 --- /dev/null +++ b/backend/project/endpoints/courses/course_details.py @@ -0,0 +1,111 @@ +""" +This file contains the api endpoint for the /courses/course_id url +This file is responsible for handling the requests made to the /courses/course_id url +and returning the appropriate response as well as handling the requests made to the +/courses/course_id/admins and /courses/course_id/students urls +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource +from sqlalchemy.exc import SQLAlchemyError + +from project.models.courses import Course +from project.models.course_relations import CourseAdmin, CourseStudent + +from project import db +from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseByCourseId(Resource): + """Api endpoint for the /courses/course_id link""" + + def get(self, course_id): + """ + This get function will return all the related projects of the course + in the following form: + { + course: course with course_id + projects: [ + list of all projects that have course_id + where projects are jsons containing the title, deadline and project_id + ] + } + """ + try: + course_details = db.session.query( + Course.course_id, + Course.name, + Course.ufora_id, + Course.teacher + ).filter( + Course.course_id == course_id).first() + + if not course_details: + return { + "message": "Course not found", + "url": RESPONSE_URL + }, 404 + + admins = db.session.query(CourseAdmin.uid).filter( + CourseAdmin.course_id == course_id + ).all() + + students = db.session.query(CourseStudent.uid).filter( + CourseStudent.course_id == course_id + ).all() + + user_url = urljoin(API_URL + "/", "users") + + admin_ids = [ urljoin(user_url + "/" , admin[0]) for admin in admins] + student_ids = [ urljoin(user_url + "/", student[0]) for student in students] + + result = { + 'course_id': course_details.course_id, + 'name': course_details.name, + 'ufora_id': course_details.ufora_id, + 'teacher': course_details.teacher, + 'admins': admin_ids, + 'students': student_ids + } + + return { + "message": "Succesfully retrieved course with course_id: " + str(course_id), + "data": result, + "url": urljoin(RESPONSE_URL + "/", str(course_id)) + } + except SQLAlchemyError: + return { + "error": "Something went wrong while querying the database.", + "url": RESPONSE_URL}, 500 + + def delete(self, course_id): + """ + This function will delete the course with course_id + """ + return delete_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL + ) + + def patch(self, course_id): + """ + This function will update the course with course_id + """ + + return patch_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL, + request.json + ) diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py new file mode 100644 index 00000000..3958422f --- /dev/null +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -0,0 +1,113 @@ +""" +This file contains the class CourseToAddStudents which is a +resource for the /courses/course_id/students link. +This class will allow admins of a course to assign and remove students from courses, +and everyone should be able to list all students assigned to a course. +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource + +from project import db +from project.models.course_relations import CourseStudent +from project.endpoints.courses.courses_utils import ( + execute_query_abort_if_db_error, + add_abort_if_error, + commit_abort_if_error, + delete_abort_if_error, + get_course_abort_if_not_found, + abort_if_none_uid_student_uids_or_non_existant_course_id, + json_message, +) + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseToAddStudents(Resource): + """ + Class that will respond to the /courses/course_id/students link + teachers should be able to assign and remove students from courses, + and everyone should be able to list all students assigned to a course + """ + + def get(self, course_id): + """ + Get function at /courses/course_id/students + to get all the users assigned to a course + everyone can get this data so no need to have uid query in the link + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + get_course_abort_if_not_found(course_id) + + query = CourseStudent.query.filter_by(course_id=course_id) + student_uids = [ + API_URL + "/users/" + s.uid + for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) + ] + response = json_message( + "Succesfully retrieved all students of course " + str(course_id) + ) + response["data"] = student_uids + response["url"] = abort_url + return response + + def post(self, course_id): + """ + Allows admins of a course to assign new students by posting to: + /courses/course_id/students with a list of uid in the request body under key "students" + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + db.session.rollback() + message = ( + "Student with uid " + uid + " is already assigned to the course" + ) + return json_message(message), 400 + add_abort_if_error(CourseStudent(uid=uid, course_id=course_id), abort_url) + commit_abort_if_error(abort_url) + response = json_message("User were succesfully added to the course") + response["url"] = abort_url + data = {"students": [API_URL + "/users/" + uid for uid in student_uids]} + response["data"] = data + return response, 201 + + def delete(self, course_id): + """ + This function allows admins of a course to remove students by sending a delete request to + /courses/course_id/students with inside the request body + a field "students" = [list of uids to unassign] + """ + abort_url = API_URL + "/courses/" + str(course_id) + "/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + delete_abort_if_error(student_relation, abort_url) + commit_abort_if_error(abort_url) + + response = json_message("User were succesfully removed from the course") + response["url"] = API_URL + "/courses/" + str(course_id) + "/students" + return response diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py new file mode 100644 index 00000000..c06d7dfc --- /dev/null +++ b/backend/project/endpoints/courses/courses.py @@ -0,0 +1,51 @@ +""" +This file contains the main endpoint for the /courses url. +This endpoint is used to get all courses and filter by given +query parameter like /courses?parameter=... +parameters can be either one of the following: teacher,ufora_id,name. +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource + +from project.models.courses import Course +from project.utils.query_agent import query_selected_from_model, insert_into_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseForUser(Resource): + """Api endpoint for the /courses link""" + + def get(self): + """ " + Get function for /courses this will be the main endpoint + to get all courses and filter by given query parameter like /courses?parameter=... + parameters can be either one of the following: teacher,ufora_id,name. + """ + + return query_selected_from_model( + Course, + RESPONSE_URL, + url_mapper={"course_id": RESPONSE_URL}, + filters=request.args + ) + + def post(self): + """ + This function will create a new course + if the body of the post contains a name and uid is an admin or teacher + """ + + return insert_into_model( + Course, + request.json, + RESPONSE_URL, + "course_id", + required_fields=["name", "teacher"] + ) diff --git a/backend/project/endpoints/courses/courses_config.py b/backend/project/endpoints/courses/courses_config.py new file mode 100644 index 00000000..f791031f --- /dev/null +++ b/backend/project/endpoints/courses/courses_config.py @@ -0,0 +1,32 @@ +""" +This file is used to configure the courses blueprint and the courses api. +It is used to define the routes for the courses blueprint and the +corresponding api endpoints. + +The courses blueprint is used to define the routes for the courses api +endpoints and the courses api is used to define the routes for the courses +api endpoints. +""" + +from flask import Blueprint +from flask_restful import Api + +from project.endpoints.courses.courses import CourseForUser +from project.endpoints.courses.course_details import CourseByCourseId +from project.endpoints.courses.course_admin_relation import CourseForAdmins +from project.endpoints.courses.course_student_relation import CourseToAddStudents + +courses_bp = Blueprint("courses", __name__) +courses_api = Api(courses_bp) + +courses_bp.add_url_rule("/courses", + view_func=CourseForUser.as_view('course_endpoint')) + +courses_bp.add_url_rule("/courses/", + view_func=CourseByCourseId.as_view('course_by_course_id')) + +courses_bp.add_url_rule("/courses//admins", + view_func=CourseForAdmins.as_view('course_admins')) + +courses_bp.add_url_rule("/courses//students", + view_func=CourseToAddStudents.as_view('course_students')) diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py new file mode 100644 index 00000000..5543db33 --- /dev/null +++ b/backend/project/endpoints/courses/courses_utils.py @@ -0,0 +1,234 @@ +""" +This module contains utility functions for the courses endpoints. +The functions are used to interact with the database and handle errors. +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv +from flask import abort +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.course_relations import CourseAdmin +from project.models.users import User +from project.models.courses import Course + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +def execute_query_abort_if_db_error(query, url, query_all=False): + """ + Execute the given SQLAlchemy query and handle any SQLAlchemyError that might occur. + If query_all == True, the query will be executed with the all() method, + otherwise with the first() method. + Args: + query (Query): The SQLAlchemy query to execute. + + Returns: + ResultProxy: The result of the query if successful, otherwise aborts with error 500. + """ + try: + if query_all: + result = query.all() + else: + result = query.first() + except SQLAlchemyError as e: + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + return result + + +def add_abort_if_error(to_add, url): + """ + Add a new object to the database + and handle any SQLAlchemyError that might occur. + + Args: + to_add (object): The object to add to the database. + """ + try: + db.session.add(to_add) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def delete_abort_if_error(to_delete, url): + """ + Deletes the given object from the database + and aborts the request with a 500 error if a SQLAlchemyError occurs. + + Args: + - to_delete: The object to be deleted from the database. + """ + try: + db.session.delete(to_delete) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def commit_abort_if_error(url): + """ + Commit the current session and handle any SQLAlchemyError that might occur. + """ + try: + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): + """ + Check if the current user is authorized to appoint new admins to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + HTTPException: If the current user is not authorized or + if the UID of the person to be made an admin is missing in the request body. + """ + url = API_URL + "/courses/" + str(course_id) + "/admins" + abort_if_uid_is_none(teacher, url) + + course = get_course_abort_if_not_found(course_id) + + if teacher != course.teacher: + response = json_message("Only the teacher of a course can appoint new admins") + response["url"] = url + abort(403, description=response) + + if not assistant: + response = json_message( + "uid of person to make admin is required in the request body" + ) + response["url"] = url + abort(400, description=response) + + +def abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids +): + """ + Check the request to assign new students to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + 403: If the user is not authorized to assign new students to the course. + 400: If the request body does not contain the required 'students' field. + """ + url = API_URL + "/courses/" + str(course_id) + "/students" + get_course_abort_if_not_found(course_id) + abort_if_no_user_found_for_uid(uid, url) + query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, url) + if not admin_relation: + message = "Not authorized to assign new students to course with id " + str( + course_id + ) + response = json_message(message) + response["url"] = url + abort(403, description=response) + + if not student_uids: + message = """To assign new students to a course, + you should have a students field with a list of uids in the request body""" + response = json_message(message) + response["url"] = url + abort(400, description=response) + + +def abort_if_uid_is_none(uid, url): + """ + Check whether the uid is None if so + abort with error 400 + """ + if uid is None: + response = json_message("There should be a uid in the request query") + response["url"] = url + abort(400, description=response) + + +def abort_if_no_user_found_for_uid(uid, url): + """ + Check if a user exists based on the provided uid. + + Args: + uid (int): The unique identifier of the user. + + Raises: + NotFound: If the user with the given uid is not found. + """ + query = User.query.filter_by(uid=uid) + user = execute_query_abort_if_db_error(query, url) + + if not user: + response = json_message("User with uid " + uid + " was not found") + response["url"] = url + abort(404, description=response) + return user + + +def get_admin_relation(uid, course_id): + """ + Retrieve the CourseAdmin object for the given uid and course. + + Args: + uid (int): The user ID. + course_id (int): The course ID. + + Returns: + CourseAdmin: The CourseAdmin object if the user is an admin, otherwise None. + """ + return execute_query_abort_if_db_error( + CourseAdmin.query.filter_by(uid=uid, course_id=course_id), + url=API_URL + "/courses/" + str(course_id) + "/admins", + ) + + +def json_message(message): + """ + Create a json message with the given message. + + Args: + message (str): The message to include in the json. + + Returns: + dict: The message in a json format. + """ + return {"message": message} + + +def get_course_abort_if_not_found(course_id): + """ + Get a course by its ID. + + Args: + course_id (int): The course ID. + + Returns: + Course: The course with the given ID. + """ + query = Course.query.filter_by(course_id=course_id) + course = execute_query_abort_if_db_error(query, API_URL + "/courses") + + if not course: + response = json_message("Course not found") + response["url"] = API_URL + "/courses" + abort(404, description=response) + + return course From f4aff02310b1bff882eb26b0ecfedb90c8a4d249 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:55:56 +0100 Subject: [PATCH 098/377] fixed linting --- backend/project/utils/misc.py | 14 +++++++++++--- backend/project/utils/query_agent.py | 13 +++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index 9d313467..7fe39a8c 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -22,8 +22,6 @@ def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[st for key, value in data.items(): if key in url_mapper: data[key] = urljoin(url_mapper[key] + "/", str(value)) - print(url_mapper) - print(data) return data def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): @@ -65,4 +63,14 @@ def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): - return {key: value for key, value in data.items() if hasattr(model, key)} \ No newline at end of file + """ + Filters the data to only contain the fields of the model. + + Args: + model: DeclarativeMeta - The model to filter the data with. + data: Dict[str, str] - The data to filter. + + Returns: + A dictionary with the fields of the model. + """ + return {key: value for key, value in data.items() if hasattr(model, key)} diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index d0595afd..bbbcf118 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -7,7 +7,7 @@ from typing import Dict, List, Union from urllib.parse import urljoin from flask import jsonify -from sqlalchemy import and_, inspect +from sqlalchemy import and_ from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError @@ -54,7 +54,7 @@ def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, url_id_field: str, - required_fields: List[str] = []): + required_fields: List[str] = None): """ Inserts a new entry into the database giving the model corresponding to a certain table and the data to insert. @@ -69,13 +69,15 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: + if required_fields is None: + required_fields = [] # Check if all non-nullable fields are present in the data missing_fields = [field for field in required_fields if field not in data] - + if missing_fields: return {"error": f"Missing required fields: {', '.join(missing_fields)}", "url": response_url_base}, 400 - + filtered_data = filter_model_fields(model, data) new_instance: DeclarativeMeta = model(**filtered_data) db.session.add(new_instance) @@ -84,7 +86,7 @@ def insert_into_model(model: DeclarativeMeta, "data": new_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError as e: + except SQLAlchemyError: return jsonify({"error": "Something went wrong while inserting into the database.", "url": response_url_base}), 500 @@ -162,7 +164,6 @@ def query_by_id_from_model(model: DeclarativeMeta, result: Query = model.query.filter(getattr(model, column_name) == column_id).first() if not result: return {"message": "Resource not found", "url": base_url}, 404 - print(column_id) return jsonify({ "data": result, "message": "Resource fetched correctly", From 4e0945f7ee3d49ba912dc88ae9c221a4f31d1a63 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 21:57:15 +0100 Subject: [PATCH 099/377] added new courses blueprint --- backend/project/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 33450700..b0c21275 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -6,8 +6,7 @@ from .db_in import db from .endpoints.index.index import index_bp from .endpoints.projects.project_endpoint import project_bp -from .endpoints.courses import courses_bp - +from .endpoints.courses.courses_config import courses_bp def create_app(): From 05038c97d3f57d3cbb8e85cac92d37d1c8feb433 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Wed, 6 Mar 2024 22:14:52 +0100 Subject: [PATCH 100/377] removed trailing space --- backend/tests/endpoints/courses_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 9beb64fe..0478007b 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -74,7 +74,7 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client assert response.status_code == 400 sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" - + course_admins = [ s.uid for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() From edb50d5671a21202109832f50e3b37d0394bf8b3 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:29:45 +0100 Subject: [PATCH 101/377] #15 - Trying to fix the github tests --- backend/tests/endpoints/conftest.py | 44 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 91a767aa..fc40b1a5 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -28,32 +28,37 @@ def users(): def courses(): """Return a list of courses to populate the database""" return [ - Course(course_id=1, name="AD3", teacher="brinkmann"), - Course(course_id=2, name="RAF", teacher="laermans"), + Course(name="AD3", teacher="brinkmann", autoincrement=True), + Course(name="RAF", teacher="laermans"), ] @pytest.fixture -def course_relations(): +def course_relations(courses): """Returns a list of course relations to populate the database""" + course_id_ad3 = courses[0].course_id + course_id_raf = courses[1].course_id + return [ - CourseAdmin(course_id=1, uid="brinkmann"), - CourseStudent(course_id=1, uid="student01"), - CourseStudent(course_id=1, uid="student02"), - CourseAdmin(course_id=2, uid="laermans"), - CourseStudent(course_id=2, uid="student02") + CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), + CourseStudent(course_id=course_id_ad3, uid="student01"), + CourseStudent(course_id=course_id_ad3, uid="student02"), + CourseAdmin(course_id=course_id_raf, uid="laermans"), + CourseStudent(course_id=course_id_raf, uid="student02") ] @pytest.fixture -def projects(): +def projects(courses): """Return a list of projects to populate the database""" + course_id_ad3 = courses[0].course_id + course_id_raf = courses[1].course_id + return [ Project( - project_id=1, title="B+ Trees", descriptions="Implement B+ trees", assignment_file="assignement.pdf", deadline=datetime(2024,3,15,13,0,0), - course_id=1, + course_id=course_id_ad3, visible_for_students=True, archieved=False, test_path="/tests", @@ -61,12 +66,11 @@ def projects(): regex_expressions=["*"] ), Project( - project_id=2, title="Predicaten", descriptions="Predicaten project", assignment_file="assignment.pdf", deadline=datetime(2023,3,15,13,0,0), - course_id=2, + course_id=course_id_raf, visible_for_students=False, archieved=True, test_path="/tests", @@ -76,30 +80,30 @@ def projects(): ] @pytest.fixture -def submissions(): +def submissions(projects): """Return a list of submissions to populate the database""" + project_id_ad3 = projects[0].project_id + project_id_raf = projects[1].project_id + return [ Submission( - submission_id=1, uid="student01", - project_id=1, + project_id=project_id_ad3, grading=16, submission_time=datetime(2024,3,14,12,0,0), submission_path="/submissions/1", submission_status=True ), Submission( - submission_id=2, uid="student02", - project_id=1, + project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59), submission_path="/submissions/2", submission_status=False ), Submission( - submission_id=3, uid="student02", - project_id=2, + project_id=project_id_raf, grading=15, submission_time=datetime(2023,3,5,10,0,0), submission_path="/submissions/3", From c0dbcd71d86935ad11684c859bc2cfc6ce8ac090 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 6 Mar 2024 22:34:49 +0100 Subject: [PATCH 102/377] #15 - Trying to fix the github tests 2 --- backend/tests/endpoints/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index fc40b1a5..8943136c 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -28,7 +28,7 @@ def users(): def courses(): """Return a list of courses to populate the database""" return [ - Course(name="AD3", teacher="brinkmann", autoincrement=True), + Course(name="AD3", teacher="brinkmann"), Course(name="RAF", teacher="laermans"), ] From a142a88d037dec1b355cb49f165bde6e22a1d4c7 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Thu, 7 Mar 2024 09:24:24 +0100 Subject: [PATCH 103/377] changed test to stay consistent with course admin relation also --- backend/tests/endpoints/courses_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 0478007b..ac299f01 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -113,7 +113,7 @@ def test_get_courses(self, courses_get_db, client, api_url): assert response.status_code == 200 sel2_students = [ - f"{api_url}/users/" + s.uid + {"uid": f"{api_url}/users/" + s.uid} for s in CourseStudent.query.filter_by(course_id=course.course_id).all() ] From 1f11956b43536811fa59dd6a658c0c00bc05a998 Mon Sep 17 00:00:00 2001 From: abuzogan Date: Thu, 7 Mar 2024 09:25:16 +0100 Subject: [PATCH 104/377] added query agent functions to prevent code duplication --- .../courses/course_admin_relation.py | 37 +++++++------------ .../courses/course_student_relation.py | 18 ++++----- 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index e4bc4b4e..229694f7 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -7,20 +7,20 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from flask import jsonify, request +from flask import request from flask_restful import Resource from project.models.course_relations import CourseAdmin from project.models.users import User from project.endpoints.courses.courses_utils import ( execute_query_abort_if_db_error, - add_abort_if_error, commit_abort_if_error, delete_abort_if_error, get_course_abort_if_not_found, abort_if_not_teacher_or_none_assistant, json_message ) +from project.utils.query_agent import query_selected_from_model, insert_into_model load_dotenv() API_URL = getenv("API_HOST") @@ -39,17 +39,13 @@ def get(self, course_id): abort_url = API_URL + "/courses/" + str(course_id) + "/admins" get_course_abort_if_not_found(course_id) - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + a.uid - for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all admins of course " + str(course_id) + return query_selected_from_model( + CourseAdmin, + abort_url, + select_values=["uid"], + url_mapper={"uid": urljoin(API_URL + "/", "users")}, + filters={"course_id": course_id}, ) - response["data"] = admin_uids - response["url"] = abort_url - return jsonify(admin_uids) def post(self, course_id): """ @@ -69,19 +65,12 @@ def post(self, course_id): ) return json_message(message), 404 - admin_relation = CourseAdmin(uid=assistant, course_id=course_id) - add_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - response = json_message( - f"Admin assistant added to course {course_id}" + return insert_into_model( + CourseAdmin, + {"uid": assistant, "course_id": course_id}, + abort_url, + "uid" ) - response["url"] = abort_url - data = { - "course_id": API_URL + "/courses/" + str(course_id), - "uid": API_URL + "/users/" + assistant, - } - response["data"] = data - return response, 201 def delete(self, course_id): """ diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 3958422f..a5488e47 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -25,6 +25,8 @@ json_message, ) +from project.utils.query_agent import query_selected_from_model + load_dotenv() API_URL = getenv("API_HOST") RESPONSE_URL = urljoin(API_URL + "/", "courses") @@ -45,17 +47,13 @@ def get(self, course_id): abort_url = API_URL + "/courses/" + str(course_id) + "/students" get_course_abort_if_not_found(course_id) - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + s.uid - for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all students of course " + str(course_id) + return query_selected_from_model( + CourseStudent, + abort_url, + select_values=["uid"], + url_mapper={"uid": urljoin(API_URL + "/", "users")}, + filters={"course_id": course_id} ) - response["data"] = student_uids - response["url"] = abort_url - return response def post(self, course_id): """ From b08a1879a4c228438926b79fa3d216c415bbee56 Mon Sep 17 00:00:00 2001 From: cmekeirl Date: Thu, 7 Mar 2024 09:50:32 +0100 Subject: [PATCH 105/377] updated index openapi --- .../endpoints/index/OpenAPI_Object.json | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 4152afb6..3409a17b 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -245,7 +245,24 @@ "data": { "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "course_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "ufora_id": { + "type": "string" + }, + "teacher": { + "type": "string" + }, + "url": { + "type": "string" + } + } } }, "url": { From 7fad876efea2652296f831e3e1561e623952c679 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 7 Mar 2024 10:27:10 +0100 Subject: [PATCH 106/377] first version of file uploads --- backend/project/__main__.py | 2 ++ backend/project/endpoints/projects/endpoint_parser.py | 4 +++- backend/project/endpoints/projects/project_endpoint.py | 1 - backend/project/endpoints/projects/projects.py | 10 +++++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index a4bd122b..32547c6e 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,5 +1,7 @@ """Main entry point for the application.""" +from sys import path +path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 87f61e69..99452929 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -3,11 +3,12 @@ """ from flask_restful import reqparse +import werkzeug parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title') parser.add_argument('descriptions', type=str, help='Projects description') -parser.add_argument('assignment_file', type=str, help='Projects assignment file') +parser.add_argument('assignment_file', type=werkzeug.datastructures.FileStorage, help='Projects assignment file', location="form") parser.add_argument("deadline", type=str, help='Projects deadline') parser.add_argument("course_id", type=str, help='Projects course_id') parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students') @@ -23,6 +24,7 @@ def parse_project_params(): """ args = parser.parse_args() result_dict = {} + print(args) for key, value in args.items(): if value is not None: diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index c996a514..eef5b34d 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -10,7 +10,6 @@ from project.endpoints.projects.project_detail import ProjectDetail project_bp = Blueprint('project_endpoint', __name__) -project_endpoint = Api(project_bp) project_bp.add_url_rule( '/projects', diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 68600034..a72957ba 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -1,6 +1,7 @@ """ Module that implements the /projects endpoint of the API """ +import os from os import getenv from urllib.parse import urljoin @@ -11,6 +12,8 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model API_URL = getenv('API_HOST') +UPLOAD_FOLDER = '/project/endpoints/uploads/' +ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} class ProjectsEndpoint(Resource): """ @@ -40,4 +43,9 @@ def post(self): using flask_restfull parse lib """ - return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) + file = request.files["assignment_file"] + + # save the file that is given with the request + file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + # return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) + return {}, 200 From ca42936bb517e64a7bd14a1c392260faa5a0d719 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 7 Mar 2024 12:34:31 +0100 Subject: [PATCH 107/377] formatting json for posting in the db --- .../project/endpoints/projects/projects.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index a72957ba..ab3a513a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -12,8 +12,12 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model API_URL = getenv('API_HOST') -UPLOAD_FOLDER = '/project/endpoints/uploads/' -ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'} +UPLOAD_FOLDER = getenv('UPLOAD_URL') +ALLOWED_EXTENSIONS = {'zip'} + +def allowed_file(filename: str): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS class ProjectsEndpoint(Resource): """ @@ -46,6 +50,18 @@ def post(self): file = request.files["assignment_file"] # save the file that is given with the request - file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + if allowed_file(file.filename): + file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + else: + print("no zip file given") # return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) - return {}, 200 + print(request.form) + project_json = {} + for key, value in request.form.items(): + print("key: {}, value: {}".format(key, value)) + project_json[key] = value + print(project_json) + new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects")) + print(new_project) + + return new_project From 907c1f452e12dfe5b78ef72011f3aa9861db367d Mon Sep 17 00:00:00 2001 From: warre Date: Thu, 7 Mar 2024 12:36:49 +0100 Subject: [PATCH 108/377] Message changes --- backend/project/endpoints/users.py | 24 ++++++++++++------------ backend/tests/endpoints/user_test.py | 12 ++++++------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 6260f302..b8f883e9 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -4,7 +4,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.users import Users as userModel +from project.models.users import User as userModel users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -33,7 +33,7 @@ def post(self): if is_teacher is None or is_admin is None or uid is None: return { - "Message": "Invalid request data!", + "message": "Invalid request data!", "Correct Format": { "uid": "User ID (string)", "is_teacher": "Teacher status (boolean)", @@ -44,7 +44,7 @@ def post(self): user = db.session.get(userModel, uid) if user is not None: # bad request, error code could be 409 but is rarely used - return {"Message": f"User {uid} already exists"}, 400 + return {"message": f"User {uid} already exists"}, 400 # Code to create a new user in the database using the uid, is_teacher, and is_admin new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) db.session.add(new_user) @@ -53,9 +53,9 @@ def post(self): except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"Message": "An error occurred while creating the user"}, 500 + return {"message": "An error occurred while creating the user"}, 500 - return {"Message": "User created successfully!"}, 201 + return {"message": "User created successfully!"}, 201 class User(Resource): @@ -68,7 +68,7 @@ def get(self, user_id): """ user = db.session.get(userModel, user_id) if user is None: - return {"Message": "User not found!"}, 404 + return {"message": "User not found!"}, 404 return jsonify(user) @@ -85,7 +85,7 @@ def patch(self, user_id): try: user = db.session.get(userModel, user_id) if user is None: - return {"Message": "User not found!"}, 404 + return {"message": "User not found!"}, 404 if is_teacher is not None: user.is_teacher = is_teacher @@ -97,8 +97,8 @@ def patch(self, user_id): except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"Message": "An error occurred while patching the user"}, 500 - return {"Message": "User updated successfully!"} + return {"message": "An error occurred while patching the user"}, 500 + return {"message": "User updated successfully!"} def delete(self, user_id): """ @@ -108,15 +108,15 @@ def delete(self, user_id): try: user = db.session.get(userModel, user_id) if user is None: - return {"Message": "User not found!"}, 404 + return {"message": "User not found!"}, 404 db.session.delete(user) db.session.commit() except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"Message": "An error occurred while deleting the user"}, 500 - return {"Message": "User deleted successfully!"} + return {"message": "An error occurred while deleting the user"}, 500 + return {"message": "User deleted successfully!"} users_api.add_resource(Users, "/users") diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 4d69801a..4953b05b 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -10,7 +10,7 @@ import pytest from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine -from project.models.users import Users +from project.models.users import User from project import db from tests import db_url @@ -23,9 +23,9 @@ def user_db_session(): db.metadata.create_all(engine) session = Session() session.add_all( - [Users(uid="del", is_admin=False, is_teacher=True), - Users(uid="pat", is_admin=False, is_teacher=True), - Users(uid="u_get", is_admin=False, is_teacher=True) + [User(uid="del", is_admin=False, is_teacher=True), + User(uid="pat", is_admin=False, is_teacher=True), + User(uid="u_get", is_admin=False, is_teacher=True) ] ) session.commit() @@ -43,7 +43,7 @@ def test_delete_user(self, client,user_db_session): # Delete the user response = client.delete("/users/del") assert response.status_code == 200 - assert response.json == {"Message": "User deleted successfully!"} + assert response.json == {"message": "User deleted successfully!"} def test_delete_not_present(self, client,user_db_session): """Test deleting a user that does not exist.""" @@ -92,7 +92,7 @@ def test_patch_user(self, client, user_db_session): 'is_admin': True }) assert response.status_code == 200 - assert response.json == {"Message": "User updated successfully!"} + assert response.json == {"message": "User updated successfully!"} def test_patch_non_existent(self, client,user_db_session): """Test updating a non-existent user.""" From 8d75ae6c05269d2fca1353154f7c291d17a0f665 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 7 Mar 2024 13:01:48 +0100 Subject: [PATCH 109/377] working file upload system, reused parser --- .../endpoints/projects/endpoint_parser.py | 18 ++++++------ .../project/endpoints/projects/projects.py | 28 +++++++++++++------ backend/project/utils/query_agent.py | 4 ++- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 99452929..d5ece633 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -6,16 +6,16 @@ import werkzeug parser = reqparse.RequestParser() -parser.add_argument('title', type=str, help='Projects title') -parser.add_argument('descriptions', type=str, help='Projects description') +parser.add_argument('title', type=str, help='Projects title', location="form") +parser.add_argument('descriptions', type=str, help='Projects description', location="form") parser.add_argument('assignment_file', type=werkzeug.datastructures.FileStorage, help='Projects assignment file', location="form") -parser.add_argument("deadline", type=str, help='Projects deadline') -parser.add_argument("course_id", type=str, help='Projects course_id') -parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students') -parser.add_argument("archieved", type=bool, help='Projects') -parser.add_argument("test_path", type=str, help='Projects test path') -parser.add_argument("script_name", type=str, help='Projects test script path') -parser.add_argument("regex_expressions", type=str, help='Projects regex expressions') +parser.add_argument("deadline", type=str, help='Projects deadline', location="form") +parser.add_argument("course_id", type=str, help='Projects course_id', location="form") +parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students', location="form") +parser.add_argument("archieved", type=bool, help='Projects', location="form") +parser.add_argument("test_path", type=str, help='Projects test path', location="form") +parser.add_argument("script_name", type=str, help='Projects test script path', location="form") +parser.add_argument("regex_expressions", type=str, help='Projects regex expressions', location="form") def parse_project_params(): diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ab3a513a..af303cc9 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -11,10 +11,24 @@ from project.models.projects import Project from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.endpoints.projects.endpoint_parser import parse_project_params + API_URL = getenv('API_HOST') UPLOAD_FOLDER = getenv('UPLOAD_URL') ALLOWED_EXTENSIONS = {'zip'} +def parse_immutabledict(request): + output_json = {} + for key, value in request.form.items(): + if value == "false": + print("false") + output_json[key] = False + if value == "true": + output_json[key] = True + else: + output_json[key] = value + return output_json + def allowed_file(filename: str): return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @@ -48,20 +62,16 @@ def post(self): """ file = request.files["assignment_file"] + project_json = parse_project_params() + print("args") + print(arg) # save the file that is given with the request if allowed_file(file.filename): file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) else: print("no zip file given") - # return insert_into_model(Project, request.json, urljoin(API_URL, "/projects")) - print(request.form) - project_json = {} - for key, value in request.form.items(): - print("key: {}, value: {}".format(key, value)) - project_json[key] = value - print(project_json) - new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects")) - print(new_project) + + new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects"), "project_id", required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"]) return new_project diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index bbbcf118..24e857e2 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -86,7 +86,9 @@ def insert_into_model(model: DeclarativeMeta, "data": new_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError: + except SQLAlchemyError as e: + print("error") + print(e) return jsonify({"error": "Something went wrong while inserting into the database.", "url": response_url_base}), 500 From fdc4e25494c7c905c7529702d1fcde269fba0d95 Mon Sep 17 00:00:00 2001 From: warre Date: Thu, 7 Mar 2024 17:01:45 +0100 Subject: [PATCH 110/377] added data to requests --- backend/project/endpoints/users.py | 30 +++++++++++++++++++++++----- backend/tests/endpoints/user_test.py | 6 +++--- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index b8f883e9..a8f1256c 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -1,4 +1,7 @@ """Users api endpoint""" +from os import getenv + +from dotenv import load_dotenv from flask import Blueprint, request, jsonify from flask_restful import Resource, Api from sqlalchemy.exc import SQLAlchemyError @@ -9,6 +12,8 @@ users_bp = Blueprint("users", __name__) users_api = Api(users_bp) +load_dotenv() +API_URL = getenv("API_HOST") class Users(Resource): """Api endpoint for the /users route""" @@ -20,7 +25,8 @@ def get(self): """ users = userModel.query.all() - return jsonify(users) + result = jsonify({"message": "Queried all users", "data": users, "url":f"{API_URL}/users/"}) + return result def post(self): """ @@ -54,8 +60,12 @@ def post(self): # every exception should result in a rollback db.session.rollback() return {"message": "An error occurred while creating the user"}, 500 - - return {"message": "User created successfully!"}, 201 + user_js = { + 'uid': user.uid, + 'is_teacher': user.is_teacher, + 'is_admin': user.is_admin + } + return {"message": "User created successfully!", "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 201 class User(Resource): @@ -70,7 +80,12 @@ def get(self, user_id): if user is None: return {"message": "User not found!"}, 404 - return jsonify(user) + user_js = { + 'uid': user.uid, + 'is_teacher': user.is_teacher, + 'is_admin': user.is_admin + } + return {"message": "User queried","data":user_js, "url": f"{API_URL}/users/{user.uid}"}, 200 def patch(self, user_id): """ @@ -98,7 +113,12 @@ def patch(self, user_id): # every exception should result in a rollback db.session.rollback() return {"message": "An error occurred while patching the user"}, 500 - return {"message": "User updated successfully!"} + user_js = { + 'uid': user.uid, + 'is_teacher': user.is_teacher, + 'is_admin': user.is_admin + } + return {"message": "User updated successfully!", "data": user_js, "url": f"{API_URL}/users/{user.uid}"} def delete(self, user_id): """ diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 4953b05b..c1c875ca 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -73,13 +73,13 @@ def test_get_all_users(self, client,user_db_session): response = client.get("/users") assert response.status_code == 200 # Check that the response is a list (even if it's empty) - assert isinstance(response.json, list) + assert isinstance(response.json["data"], list) def test_get_one_user(self, client,user_db_session): """Test getting a single user.""" response = client.get("users/u_get") assert response.status_code == 200 - assert response.json == { + assert response.json["data"] == { 'uid': 'u_get', 'is_teacher': True, 'is_admin': False @@ -92,7 +92,7 @@ def test_patch_user(self, client, user_db_session): 'is_admin': True }) assert response.status_code == 200 - assert response.json == {"message": "User updated successfully!"} + assert response.json["message"] == "User updated successfully!" def test_patch_non_existent(self, client,user_db_session): """Test updating a non-existent user.""" From fb556fbb40ccc875fdc672217d30636714607a7c Mon Sep 17 00:00:00 2001 From: warre Date: Thu, 7 Mar 2024 17:04:36 +0100 Subject: [PATCH 111/377] pylint complaints --- backend/project/endpoints/users.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index a8f1256c..ad8219d6 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -65,7 +65,8 @@ def post(self): 'is_teacher': user.is_teacher, 'is_admin': user.is_admin } - return {"message": "User created successfully!", "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 201 + return {"message": "User created successfully!", + "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 201 class User(Resource): @@ -118,7 +119,8 @@ def patch(self, user_id): 'is_teacher': user.is_teacher, 'is_admin': user.is_admin } - return {"message": "User updated successfully!", "data": user_js, "url": f"{API_URL}/users/{user.uid}"} + return {"message": "User updated successfully!", + "data": user_js, "url": f"{API_URL}/users/{user.uid}"} def delete(self, user_id): """ From acbb1b25dceb6facfc472ebf71bcc8e9022a6ef6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 7 Mar 2024 18:11:54 +0100 Subject: [PATCH 112/377] #15 - Changing responses --- .../endpoints/index/OpenAPI_Object.json | 165 ++++++------------ backend/project/endpoints/submissions.py | 60 ++++--- backend/project/models/submissions.py | 6 +- backend/tests/endpoints/conftest.py | 9 +- backend/tests/endpoints/submissions_test.py | 72 ++------ 5 files changed, 115 insertions(+), 197 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 5469ec2d..3b61672a 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1370,10 +1370,6 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" }, @@ -1399,16 +1395,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1422,16 +1410,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1475,19 +1455,39 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "url": { "type": "string", "format": "uri" }, - "message": { - "type": "string" - }, "data": { "type": "object", "properties": { - "submission": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { "type": "string", "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } @@ -1503,16 +1503,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1526,16 +1518,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1556,10 +1540,6 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" }, @@ -1610,16 +1590,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1633,16 +1605,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1678,16 +1642,41 @@ "schema": { "type": "object", "properties": { + "message": { + "type": "string" + }, "url": { "type": "string", "format": "uri" }, - "message": { - "type": "string" - }, "data": { "type": "object", - "properties": {} + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" + } + } } } } @@ -1701,16 +1690,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1724,16 +1705,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1747,16 +1720,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1775,16 +1740,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1798,16 +1755,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } @@ -1821,16 +1770,8 @@ "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" - }, "message": { "type": "string" - }, - "data": { - "type": "object", - "properties": {} } } } diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 28c74ce7..b31ca529 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -26,11 +26,7 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - data = { - "url": f"{API_HOST}/submissions", - "message": "Successfully fetched the submissions", - "data": {} - } + data = {} try: with db.session() as session: query = session.query(Submission) @@ -53,7 +49,8 @@ def get(self) -> dict[str, any]: query = query.filter_by(project_id=int(project_id)) # Get the submissions - data["data"]["submissions"] = [ + data["message"] = "Successfully fetched the submissions" + data["data"] = [ f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() ] return data, 200 @@ -69,11 +66,7 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ - data = { - "url": f"{API_HOST}/submissions", - "message": "Successfully fetched the submissions", - "data": {} - } + data = {} try: with db.session() as session: submission = Submission() @@ -119,7 +112,17 @@ def post(self) -> dict[str, any]: session.add(submission) session.commit() - data["data"]["submission"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["message"] = "Successfully fetched the submissions" + data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["data"] = { + "id": submission.submission_id, + "user": f"{API_HOST}/users/{submission.uid}", + "project": f"{API_HOST}/projects/{submission.project_id}", + "grading": submission.grading, + "time": submission.submission_time, + "path": submission.submission_path, + "status": submission.submission_status + } return data, 201 except exc.SQLAlchemyError: @@ -140,11 +143,7 @@ def get(self, submission_id: int) -> dict[str, any]: dict[str, any]: The submission """ - data = { - "url": f"{API_HOST}/submissions/{submission_id}", - "message": "Successfully fetched the submission", - "data": {} - } + data = {} try: with db.session() as session: submission = session.get(Submission, submission_id) @@ -152,7 +151,8 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 - data["data"]["submission"] = { + data["message"] = "Successfully fetched the submission" + data["data"] = { "id": submission.submission_id, "user": f"{API_HOST}/users/{submission.uid}", "project": f"{API_HOST}/projects/{submission.project_id}", @@ -178,11 +178,7 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ - data = { - "url": f"{API_HOST}/submissions/{submission_id}", - "message": f"Submission (submission_id={submission_id}) patched", - "data": {} - } + data = {} try: with db.session() as session: # Get the submission @@ -202,6 +198,17 @@ def patch(self, submission_id:int) -> dict[str, any]: # Save the submission session.commit() + data["message"] = f"Submission (submission_id={submission_id}) patched" + data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["data"] = { + "id": submission.submission_id, + "user": f"{API_HOST}/users/{submission.uid}", + "project": f"{API_HOST}/projects/{submission.project_id}", + "grading": submission.grading, + "time": submission.submission_time, + "path": submission.submission_path, + "status": submission.submission_status + } return data, 200 except exc.SQLAlchemyError: @@ -220,11 +227,7 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = { - "url": f"{API_HOST}/submissions/{submission_id}", - "message": f"Submission (submission_id={submission_id}) deleted", - "data": {} - } + data = {} try: with db.session() as session: submission = session.get(Submission, submission_id) @@ -236,6 +239,7 @@ def delete(self, submission_id: int) -> dict[str, any]: session.delete(submission) session.commit() + data["message"] = f"Submission (submission_id={submission_id}) deleted" return data, 200 except exc.SQLAlchemyError: diff --git a/backend/project/models/submissions.py b/backend/project/models/submissions.py index efba63f0..1e8987cd 100644 --- a/backend/project/models/submissions.py +++ b/backend/project/models/submissions.py @@ -1,8 +1,10 @@ """Model for submissions""" -from sqlalchemy import Column,String,ForeignKey,Integer,CheckConstraint,DateTime,Boolean +from dataclasses import dataclass +from sqlalchemy import Column, String, ForeignKey, Integer, CheckConstraint, DateTime, Boolean from project.db_in import db +@dataclass class Submission(db.Model): """This class describes the submissions table, submissions can be made to a project, a submission has @@ -15,7 +17,7 @@ class Submission(db.Model): so we can easily present in a list which submission succeeded the automated checks""" __tablename__ = "submissions" - submission_id = Column(Integer, nullable=False, primary_key=True) + submission_id = Column(Integer, primary_key=True) uid = Column(String(255), ForeignKey("users.uid"), nullable=False) project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False) grading = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 8943136c..b515caf5 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -28,8 +28,8 @@ def users(): def courses(): """Return a list of courses to populate the database""" return [ - Course(name="AD3", teacher="brinkmann"), - Course(name="RAF", teacher="laermans"), + Course(course_id=1, name="AD3", teacher="brinkmann"), + Course(course_id=2, name="RAF", teacher="laermans"), ] @pytest.fixture @@ -54,6 +54,7 @@ def projects(courses): return [ Project( + project_id=1, title="B+ Trees", descriptions="Implement B+ trees", assignment_file="assignement.pdf", @@ -66,6 +67,7 @@ def projects(courses): regex_expressions=["*"] ), Project( + project_id=2, title="Predicaten", descriptions="Predicaten project", assignment_file="assignment.pdf", @@ -87,6 +89,7 @@ def submissions(projects): return [ Submission( + submission_id=1, uid="student01", project_id=project_id_ad3, grading=16, @@ -95,6 +98,7 @@ def submissions(projects): submission_status=True ), Submission( + submission_id=2, uid="student02", project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59), @@ -102,6 +106,7 @@ def submissions(projects): submission_status=False ), Submission( + submission_id=3, uid="student02", project_id=project_id_raf, grading=15, diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 7048f7d0..b64a1008 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -16,36 +16,29 @@ def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session) response = client.get("/submissions?uid=unknown") data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" - assert data["data"] == {} def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" - assert data["data"] == {} def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test getting submissions for a non-existing project of the wrong type""" response = client.get("/submissions?project_id=zero") data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" - assert data["data"] == {} def test_get_submissions_all(self, client: FlaskClient, session: Session): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2", f"{API_HOST}/submissions/3" @@ -56,9 +49,8 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): response = client.get("/submissions?uid=student01") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1" ] @@ -67,9 +59,8 @@ def test_get_submissions_project(self, client: FlaskClient, session: Session): response = client.get("/submissions?project_id=1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1", f"{API_HOST}/submissions/2" ] @@ -79,9 +70,8 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio response = client.get("/submissions?uid=student01&project_id=1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - assert data["data"]["submissions"] == [ + assert data["data"] == [ f"{API_HOST}/submissions/1" ] @@ -93,9 +83,7 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The uid data field is required" - assert data["data"] == {} def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing user""" @@ -105,9 +93,7 @@ def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid user (uid=unknown)" - assert data["data"] == {} def test_post_submissions_no_project(self, client: FlaskClient, session: Session): """Test posting a submission without specifying a project""" @@ -116,9 +102,7 @@ def test_post_submissions_no_project(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "The project_id data field is required" - assert data["data"] == {} def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project""" @@ -128,9 +112,7 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=-1)" - assert data["data"] == {} def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): """Test posting a submission for a non-existing project of the wrong type""" @@ -140,9 +122,7 @@ def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid project (project_id=zero)" - assert data["data"] == {} def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading""" @@ -153,9 +133,7 @@ def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Sess }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): """Test posting a submission with a wrong grading type""" @@ -166,9 +144,7 @@ def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: }) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" @@ -179,13 +155,11 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): }) data = response.json assert response.status_code == 201 - assert data["url"] == f"{API_HOST}/submissions" assert data["message"] == "Successfully fetched the submissions" - - submission_id = int(data["data"]["submission"].split("/")[-1]) - submission = session.get(Submission, submission_id) - assert submission.uid == "student01" and submission.project_id == 1 \ - and submission.grading == 16 + assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" + assert data["data"]["user"] == f"{API_HOST}/users/student01" + assert data["data"]["project"] == f"{API_HOST}/projects/1" + assert data["data"]["grading"] == 16 ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -193,18 +167,15 @@ def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.get("/submissions/100") data = response.json assert response.status_code == 404 - assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" - assert data["data"] == {} def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" response = client.get("/submissions/1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions/1" assert data["message"] == "Successfully fetched the submission" - assert data["data"]["submission"] == { + assert data["data"] == { "id": 1, "user": f"{API_HOST}/users/student01", "project": f"{API_HOST}/projects/1", @@ -220,39 +191,38 @@ def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): response = client.patch("/submissions/100", data={"grading": 20}) data = response.json assert response.status_code == 404 - assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" - assert data["data"] == {} def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" response = client.patch("/submissions/2", data={"grading": 100}) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading type""" response = client.patch("/submissions/2", data={"grading": "zero"}) data = response.json assert response.status_code == 400 - assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Invalid grading (grading=0-20)" - assert data["data"] == {} def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" response = client.patch("/submissions/2", data={"grading": 20}) data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions/2" assert data["message"] == "Submission (submission_id=2) patched" - assert data["data"] == {} - - submission = session.get(Submission, 2) - assert submission.grading == 20 + assert data["url"] == f"{API_HOST}/submissions/2" + assert data["data"] == { + "id": 2, + "user": f"{API_HOST}/users/student02", + "project": f"{API_HOST}/projects/1", + "grading": 20, + "time": 'Thu, 14 Mar 2024 22:59:59 GMT', + "path": "/submissions/2", + "status": False + } ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -260,18 +230,14 @@ def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session) response = client.delete("submissions/100") data = response.json assert response.status_code == 404 - assert data["url"] == f"{API_HOST}/submissions/100" assert data["message"] == "Submission (submission_id=100) not found" - assert data["data"] == {} def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" response = client.delete("submissions/1") data = response.json assert response.status_code == 200 - assert data["url"] == f"{API_HOST}/submissions/1" assert data["message"] == "Submission (submission_id=1) deleted" - assert data["data"] == {} submission = session.get(Submission, 1) assert submission is None From f4440af8d85fa0767af8e7acc1acde8a29c18292 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 7 Mar 2024 18:22:33 +0100 Subject: [PATCH 113/377] #15 - Using urljoin instead of formatted string --- backend/project/endpoints/submissions.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index b31ca529..b5eff6c7 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -1,5 +1,6 @@ """Submission API endpoint""" +from urllib.parse import urljoin from datetime import datetime from os import getenv from dotenv import load_dotenv @@ -51,7 +52,7 @@ def get(self) -> dict[str, any]: # Get the submissions data["message"] = "Successfully fetched the submissions" data["data"] = [ - f"{API_HOST}/submissions/{s.submission_id}" for s in query.all() + urljoin(API_HOST, f"/submissions/{s.submission_id}") for s in query.all() ] return data, 200 @@ -113,11 +114,11 @@ def post(self) -> dict[str, any]: session.commit() data["message"] = "Successfully fetched the submissions" - data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": f"{API_HOST}/users/{submission.uid}", - "project": f"{API_HOST}/projects/{submission.project_id}", + "user": urljoin(API_HOST, f"/users/{submission.uid}"), + "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -154,8 +155,8 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = "Successfully fetched the submission" data["data"] = { "id": submission.submission_id, - "user": f"{API_HOST}/users/{submission.uid}", - "project": f"{API_HOST}/projects/{submission.project_id}", + "user": urljoin(API_HOST, f"/users/{submission.uid}"), + "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -199,11 +200,11 @@ def patch(self, submission_id:int) -> dict[str, any]: session.commit() data["message"] = f"Submission (submission_id={submission_id}) patched" - data["url"] = f"{API_HOST}/submissions/{submission.submission_id}" + data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": f"{API_HOST}/users/{submission.uid}", - "project": f"{API_HOST}/projects/{submission.project_id}", + "user": urljoin(API_HOST, f"/users/{submission.uid}"), + "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, From 5a8f71c5731eefade49ae253f75b28cdd867eb56 Mon Sep 17 00:00:00 2001 From: Aron Buzogany Date: Sat, 2 Mar 2024 12:19:18 +0100 Subject: [PATCH 114/377] only running ci on pr, Fix #44 --- .github/workflows/ci-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 10a23a0f..2420abc8 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -1,6 +1,6 @@ name: UGent-3 run-name: ${{ github.actor }} is running tests 🚀 -on: [push, pull_request] +on: [pull_request] jobs: Frontend-tests: runs-on: self-hosted From 2afa822431545cef6e61866c45c1aa2979ea4462 Mon Sep 17 00:00:00 2001 From: warre Date: Fri, 8 Mar 2024 15:36:15 +0100 Subject: [PATCH 115/377] placed return under try --- backend/project/endpoints/users.py | 55 ++++++++++++++++-------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index ad8219d6..b0f7f5df 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -25,7 +25,7 @@ def get(self): """ users = userModel.query.all() - result = jsonify({"message": "Queried all users", "data": users, "url":f"{API_URL}/users/"}) + result = jsonify({"message": "Queried all users", "data": users, "url":f"{API_URL}/users/", "status_code": 200}) return result def post(self): @@ -55,18 +55,19 @@ def post(self): new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) db.session.add(new_user) db.session.commit() + user_js = { + 'uid': user.uid, + 'is_teacher': user.is_teacher, + 'is_admin': user.is_admin + } + return {"message": "User created successfully!", + "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 201 except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() return {"message": "An error occurred while creating the user"}, 500 - user_js = { - 'uid': user.uid, - 'is_teacher': user.is_teacher, - 'is_admin': user.is_admin - } - return {"message": "User created successfully!", - "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 201 + class User(Resource): @@ -77,16 +78,19 @@ def get(self, user_id): This function will respond to GET requests made to /users/. It should return the user with the given user_id from the database. """ - user = db.session.get(userModel, user_id) - if user is None: - return {"message": "User not found!"}, 404 + try: + user = db.session.get(userModel, user_id) + if user is None: + return {"message": "User not found!"}, 404 - user_js = { - 'uid': user.uid, - 'is_teacher': user.is_teacher, - 'is_admin': user.is_admin - } - return {"message": "User queried","data":user_js, "url": f"{API_URL}/users/{user.uid}"}, 200 + user_js = { + 'uid': user.uid, + 'is_teacher': user.is_teacher, + 'is_admin': user.is_admin + } + return {"message": "User queried","data":user_js, "url": f"{API_URL}/users/{user.uid}"}, 200 + except SQLAlchemyError: + return {"message": "An error occurred while fetching the user"}, 500 def patch(self, user_id): """ @@ -110,17 +114,18 @@ def patch(self, user_id): # Save the changes to the database db.session.commit() + user_js = { + 'uid': user.uid, + 'is_teacher': user.is_teacher, + 'is_admin': user.is_admin + } + return {"message": "User updated successfully!", + "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 200 except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() return {"message": "An error occurred while patching the user"}, 500 - user_js = { - 'uid': user.uid, - 'is_teacher': user.is_teacher, - 'is_admin': user.is_admin - } - return {"message": "User updated successfully!", - "data": user_js, "url": f"{API_URL}/users/{user.uid}"} + def delete(self, user_id): """ @@ -134,11 +139,11 @@ def delete(self, user_id): db.session.delete(user) db.session.commit() + return {"message": "User deleted successfully!"}, 200 except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() return {"message": "An error occurred while deleting the user"}, 500 - return {"message": "User deleted successfully!"} users_api.add_resource(Users, "/users") From 34399b54b539b42a76f08beb4003656dc332b2f6 Mon Sep 17 00:00:00 2001 From: warre Date: Fri, 8 Mar 2024 15:38:39 +0100 Subject: [PATCH 116/377] pylint --- backend/project/endpoints/users.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index b0f7f5df..e9ec081c 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -25,7 +25,8 @@ def get(self): """ users = userModel.query.all() - result = jsonify({"message": "Queried all users", "data": users, "url":f"{API_URL}/users/", "status_code": 200}) + result = jsonify({"message": "Queried all users", "data": users, + "url":f"{API_URL}/users/", "status_code": 200}) return result def post(self): @@ -88,7 +89,8 @@ def get(self, user_id): 'is_teacher': user.is_teacher, 'is_admin': user.is_admin } - return {"message": "User queried","data":user_js, "url": f"{API_URL}/users/{user.uid}"}, 200 + return {"message": "User queried","data":user_js, + "url": f"{API_URL}/users/{user.uid}"}, 200 except SQLAlchemyError: return {"message": "An error occurred while fetching the user"}, 500 From 9d8342415cdc375986ae2b6cb2f92c89ac822615 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 8 Mar 2024 15:50:53 +0100 Subject: [PATCH 117/377] 15 - Removed grading from post --- backend/project/endpoints/submissions.py | 8 ------- backend/tests/endpoints/submissions_test.py | 26 +-------------------- 2 files changed, 1 insertion(+), 33 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index b5eff6c7..0f403d02 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -92,14 +92,6 @@ def post(self) -> dict[str, any]: return data, 400 submission.project_id = int(project_id) - # Grading - grading = request.form.get("grading") - if grading is not None: - if not (grading.isdigit() and 0 <= int(grading) <= 20): - data["message"] = "Invalid grading (grading=0-20)" - return data, 400 - submission.grading = int(grading) - # Submission time submission.submission_time = datetime.now() diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index b64a1008..5b057d3c 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -124,34 +124,11 @@ def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=zero)" - def test_post_submissions_wrong_grading(self, client: FlaskClient, session: Session): - """Test posting a submission with a wrong grading""" - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": 1, - "grading": 80 - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid grading (grading=0-20)" - - def test_post_submissions_wrong_grading_type(self, client: FlaskClient, session: Session): - """Test posting a submission with a wrong grading type""" - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": 1, - "grading": "zero" - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid grading (grading=0-20)" - def test_post_submissions_correct(self, client: FlaskClient, session: Session): """Test posting a submission""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, - "grading": 16 + "project_id": 1 }) data = response.json assert response.status_code == 201 @@ -159,7 +136,6 @@ def test_post_submissions_correct(self, client: FlaskClient, session: Session): assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" assert data["data"]["user"] == f"{API_HOST}/users/student01" assert data["data"]["project"] == f"{API_HOST}/projects/1" - assert data["data"]["grading"] == 16 ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): From cd5201809cc2cd2c90a548fa15dd1b18d6e90a63 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 8 Mar 2024 16:10:51 +0100 Subject: [PATCH 118/377] #15 - Updating method descriptions --- .../endpoints/index/OpenAPI_Object.json | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 3b61672a..50c85f4f 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1343,7 +1343,7 @@ }, "/submissions": { "get": { - "summary": "Get the submissions", + "summary": "Gets the submissions", "parameters": [ { "name": "uid", @@ -1364,7 +1364,7 @@ ], "responses": { "200": { - "description": "A list of submission URLs", + "description": "Successfully retrieved a list of submission URLs", "content": { "application/json": { "schema": { @@ -1389,7 +1389,7 @@ } }, "400": { - "description": "An 'invalid data' message", + "description": "An invalid user or project is given", "content": { "application/json": { "schema": { @@ -1404,7 +1404,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1421,7 +1421,7 @@ } }, "post": { - "summary": "Post a new submission to a project", + "summary": "Posts a new submission to a project", "requestBody": { "description": "Form data", "content": { @@ -1449,7 +1449,7 @@ }, "responses": { "201": { - "description": "The newly created submission URL", + "description": "Successfully posts the submission and retrieves its data", "content": { "application/json": { "schema": { @@ -1497,7 +1497,7 @@ } }, "400": { - "description": "An 'invalid data' message", + "description": "An invalid user or project is given", "content": { "application/json": { "schema": { @@ -1512,7 +1512,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1531,10 +1531,10 @@ }, "/submissions/{submission_id}": { "get": { - "summary": "Get the submission", + "summary": "Gets the submission", "responses": { "200": { - "description": "The submission", + "description": "Successfully retrieved the submission", "content": { "application/json": { "schema": { @@ -1584,7 +1584,7 @@ } }, "404": { - "description": "A 'not found' message", + "description": "An invalid submission id is given", "content": { "application/json": { "schema": { @@ -1599,7 +1599,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1616,7 +1616,7 @@ } }, "patch": { - "summary": "Patch the submission", + "summary": "Patches the submission", "requestBody": { "description": "The submission data", "content": { @@ -1636,7 +1636,7 @@ }, "responses": { "200": { - "description": "A 'submission updated' message", + "description": "Successfully patches the submission and retrieves its data", "content": { "application/json": { "schema": { @@ -1684,7 +1684,7 @@ } }, "400": { - "description": "An 'invalid data' message", + "description": "An invalid submission grading is given", "content": { "application/json": { "schema": { @@ -1699,7 +1699,7 @@ } }, "404": { - "description": "A 'not found' message", + "description": "An invalid submission id is given", "content": { "application/json": { "schema": { @@ -1714,7 +1714,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { @@ -1731,10 +1731,10 @@ } }, "delete": { - "summary": "Delete the submission", + "summary": "Deletes the submission", "responses": { "200": { - "description": "A 'submission deleted' message", + "description": "Successfully deletes the submission", "content": { "application/json": { "schema": { @@ -1749,7 +1749,7 @@ } }, "404": { - "description": "A 'not found' message", + "description": "An invalid submission id is given", "content": { "application/json": { "schema": { @@ -1764,7 +1764,7 @@ } }, "500": { - "description": "An error message", + "description": "An internal server error occurred", "content": { "application/json": { "schema": { From 2e3ede56d799e9dfd03796ed75e0416ec4ccecb2 Mon Sep 17 00:00:00 2001 From: Gerwoud Date: Fri, 8 Mar 2024 16:31:54 +0100 Subject: [PATCH 119/377] typos and a little restructure --- backend/README.md | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/backend/README.md b/backend/README.md index abfa6401..8beb249d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,34 +1,25 @@ # Project pigeonhole backend ## Prerequisites -If you want the development environment run both commands if you only need to deploy only run the Deployment command. - -The [dev-requirements.txt](dev-requirements.txt) contains everything for writing tests and linters for maintaining quality code. -On the other hand the regular [requirements.txt](requirements.txt) install the packages needed for -the regular base application. - -- Deployment -```sh - pip install -r requirments.txt -``` -- Development -```sh - pip install -r dev-requirments.txt -``` - -## Installation -1. Clone the repo +**1. Clone the repo** ```sh git clone git@github.com:SELab-2/UGent-3.git ``` -2. If you want to develop run both commands, if you want to deploy only run deployment command. +**2. Installing required packages** + + If you want the development environment: run both commands. If you only need to deploy, run the deployment command. + + The [dev-requirements.txt](dev-requirements.txt) contains everything for writing tests and linters for maintaining quality code. +On the other hand the regular [requirements.txt](requirements.txt) installs the packages needed for +the regular base application. + - Deployment ```sh - pip install -r requirments.txt + pip install -r requirements.txt ``` - Development ```sh - pip install -r dev-requirments.txt - ``` + pip install -r dev-requirements.txt + ``` ## Setting up the environment variables The project requires a couple of environment variables to run, if you want to develop on this codebase. @@ -45,7 +36,7 @@ Setting values for these variables can be done with a method to your own liking. All the variables except the last one are for the database setup, these are needed to make a connection with the database. -The last one is for keeping the API restfull since the location of the recourse should be located. +The last one is for keeping the API restful since the location of the resource should be located. ## Running the project Once all the setup is done you can start the development server by @@ -53,8 +44,8 @@ navigating to the backend directory and running: ```sh python project ``` -The server should now be located at `localhost:5000` and you can -start developping. +The server should now be located at `localhost:5000` and you can +start developing. ## Maintaining the codebase ### Writing tests @@ -64,7 +55,7 @@ writing tests is mandatory for this, the test library used in this codebase is [ If you want to write tests we highly advise to read the pytest documentation on how to write tests, so they are kept conventional. -For executing the tests and testing you're newly added functionality +For executing the tests and testing your newly added functionality you can run: ```sh sudo ./run_tests.sh From e64860fca4b3b35ea4196636faf98881b372e408 Mon Sep 17 00:00:00 2001 From: warre Date: Fri, 8 Mar 2024 16:54:26 +0100 Subject: [PATCH 120/377] run backend self-hosted --- .github/workflows/ci-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 2420abc8..bd101643 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -37,7 +37,7 @@ jobs: working-directory: ./frontend run: npm run lint Backend-tests: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 From c125718eff92ef7d907f75a40b907d895c15a95e Mon Sep 17 00:00:00 2001 From: warre Date: Fri, 8 Mar 2024 17:31:09 +0100 Subject: [PATCH 121/377] query get users --- backend/project/endpoints/users.py | 21 +++++++++++++++++---- backend/tests/endpoints/user_test.py | 16 +++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index e9ec081c..8dd42784 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -23,11 +23,24 @@ def get(self): This function will respond to get requests made to /users. It should return all users from the database. """ - users = userModel.query.all() + try: + query = userModel.query + is_teacher = request.args.get('is_teacher') + is_admin = request.args.get('is_admin') + + if is_teacher is not None: + query = query.filter(userModel.is_teacher == (is_teacher.lower() == 'true')) - result = jsonify({"message": "Queried all users", "data": users, - "url":f"{API_URL}/users/", "status_code": 200}) - return result + if is_admin is not None: + query = query.filter(userModel.is_admin == (is_admin.lower() == 'true')) + + users = query.all() + + result = jsonify({"message": "Queried all users", "data": users, + "url":f"{API_URL}/users/", "status_code": 200}) + return result + except SQLAlchemyError: + return {"message": "An error occurred while fetching the users"}, 500 def post(self): """ diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c1c875ca..ef4395a9 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -25,7 +25,8 @@ def user_db_session(): session.add_all( [User(uid="del", is_admin=False, is_teacher=True), User(uid="pat", is_admin=False, is_teacher=True), - User(uid="u_get", is_admin=False, is_teacher=True) + User(uid="u_get", is_admin=False, is_teacher=True), + User(uid="query_user", is_admin=True, is_teacher=False) ] ) session.commit() @@ -110,3 +111,16 @@ def test_patch_non_json(self, client,user_db_session): 'is_admin': False }) assert response.status_code == 415 + + def test_get_users_with_query(self, client, user_db_session): + """Test getting users with a query.""" + # Send a GET request with query parameters + response = client.get("/users?is_admin=true&is_teacher=false") + assert response.status_code == 200 + + # Check that the response contains only the user that matches the query + users = response.json["data"] + assert len(users) == 1 + assert users[0]["uid"] == "query_user" + assert users[0]["is_admin"] == True + assert users[0]["is_teacher"] == False From 60e0183f71de61722d3ed0f26729a31d219fb8af Mon Sep 17 00:00:00 2001 From: warre Date: Fri, 8 Mar 2024 17:33:27 +0100 Subject: [PATCH 122/377] small comment --- backend/tests/endpoints/user_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index ef4395a9..06146250 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -114,7 +114,7 @@ def test_patch_non_json(self, client,user_db_session): def test_get_users_with_query(self, client, user_db_session): """Test getting users with a query.""" - # Send a GET request with query parameters + # Send a GET request with query parameters, this is a nonsense entry but good for testing response = client.get("/users?is_admin=true&is_teacher=false") assert response.status_code == 200 From 09f132555e35028fc9a938d12f421798429ffde4 Mon Sep 17 00:00:00 2001 From: warre Date: Fri, 8 Mar 2024 17:39:08 +0100 Subject: [PATCH 123/377] pylint --- backend/tests/endpoints/user_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 06146250..438bf8c3 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -122,5 +122,5 @@ def test_get_users_with_query(self, client, user_db_session): users = response.json["data"] assert len(users) == 1 assert users[0]["uid"] == "query_user" - assert users[0]["is_admin"] == True - assert users[0]["is_teacher"] == False + assert users[0]["is_admin"] is True + assert users[0]["is_teacher"] is False From a39473645c32cf39298111cefb3272a1cd868bf3 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 9 Mar 2024 10:15:58 +0100 Subject: [PATCH 124/377] Enhancement/endpoints cleanup (#61) * added query agent containing functions that can be used by multiple endpoints * loading env variables is only necessary in __main__ * removed unneeded load_dotenv * completed functions that are ought to be used by multiple endpoints or files * simplified endpoint functions by using query_agent functions * fixed linting * fixed urljoin incorrectly joining url * lint: removed trailing whitepsace * completely replaced functionality with query_agent functions * added functionality for patching an entry in the database * fixed linting * filtered queries and forms to only contain entries that are valid in table * created function that filters dict keys that are not in table * made class serializable * url query is not a valid authentication method, filtered out option * using query_agent functions to prevent code duplication * split courses into multiple files to keep it organized * fixed linting * added new courses blueprint * removed trailing space * changed test to stay consistent with course admin relation also * added query agent functions to prevent code duplication * using f string instead of concating string with plus operator * updated with better module doc * removed courses file (part of merge) * removed unused module * appending forward slash when using urljoin * remove unused file * fixed linting * removed unused file * removed duplicate column values --- backend/project/__init__.py | 3 +- backend/project/__main__.py | 5 +- backend/project/db_in.py | 3 - backend/project/endpoints/courses.py | 623 ------------------ .../courses/course_admin_relation.py | 100 +++ .../endpoints/courses/course_details.py | 111 ++++ .../courses/course_student_relation.py | 111 ++++ backend/project/endpoints/courses/courses.py | 51 ++ .../endpoints/courses/courses_config.py | 32 + .../endpoints/courses/courses_utils.py | 234 +++++++ .../endpoints/projects/project_detail.py | 101 +-- .../project/endpoints/projects/projects.py | 78 +-- backend/project/models/courses.py | 2 +- backend/project/sessionmaker.py | 3 - backend/project/utils/misc.py | 76 +++ backend/project/utils/query_agent.py | 210 ++++++ backend/tests/endpoints/courses_test.py | 20 +- 17 files changed, 969 insertions(+), 794 deletions(-) delete mode 100644 backend/project/endpoints/courses.py create mode 100644 backend/project/endpoints/courses/course_admin_relation.py create mode 100644 backend/project/endpoints/courses/course_details.py create mode 100644 backend/project/endpoints/courses/course_student_relation.py create mode 100644 backend/project/endpoints/courses/courses.py create mode 100644 backend/project/endpoints/courses/courses_config.py create mode 100644 backend/project/endpoints/courses/courses_utils.py create mode 100644 backend/project/utils/misc.py create mode 100644 backend/project/utils/query_agent.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 33450700..b0c21275 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -6,8 +6,7 @@ from .db_in import db from .endpoints.index.index import index_bp from .endpoints.projects.project_endpoint import project_bp -from .endpoints.courses import courses_bp - +from .endpoints.courses.courses_config import courses_bp def create_app(): diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 2f312c85..a4bd122b 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,11 +1,10 @@ """Main entry point for the application.""" -from sys import path +from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url -path.append(".") - if __name__ == "__main__": + load_dotenv() app = create_app_with_db(url) app.run(debug=True) diff --git a/backend/project/db_in.py b/backend/project/db_in.py index ebcc02dd..57a572fa 100644 --- a/backend/project/db_in.py +++ b/backend/project/db_in.py @@ -2,13 +2,10 @@ import os from flask_sqlalchemy import SQLAlchemy -from dotenv import load_dotenv from sqlalchemy import URL db = SQLAlchemy() -load_dotenv() - DATABSE_NAME = os.getenv("POSTGRES_DB") DATABASE_USER = os.getenv("POSTGRES_USER") DATABASE_PASSWORD = os.getenv("POSTGRES_PASSWORD") diff --git a/backend/project/endpoints/courses.py b/backend/project/endpoints/courses.py deleted file mode 100644 index 4a4def72..00000000 --- a/backend/project/endpoints/courses.py +++ /dev/null @@ -1,623 +0,0 @@ -"""Course api point""" - -from os import getenv -import dataclasses -from typing import List -from dotenv import load_dotenv -from flask import Blueprint, jsonify, request -from flask import abort -from flask_restful import Api, Resource -from sqlalchemy.exc import SQLAlchemyError -from project.models.course_relations import CourseAdmin, CourseStudent -from project.models.users import User -from project.models.courses import Course -from project.models.projects import Project -from project import db - -courses_bp = Blueprint("courses", __name__) -courses_api = Api(courses_bp) - -load_dotenv() -API_URL = getenv("API_HOST") - - -def execute_query_abort_if_db_error(query, url, query_all=False): - """ - Execute the given SQLAlchemy query and handle any SQLAlchemyError that might occur. - If query_all == True, the query will be executed with the all() method, - otherwise with the first() method. - Args: - query (Query): The SQLAlchemy query to execute. - - Returns: - ResultProxy: The result of the query if successful, otherwise aborts with error 500. - """ - try: - if query_all: - result = query.all() - else: - result = query.first() - except SQLAlchemyError as e: - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - return result - - -def add_abort_if_error(to_add, url): - """ - Add a new object to the database - and handle any SQLAlchemyError that might occur. - - Args: - to_add (object): The object to add to the database. - """ - try: - db.session.add(to_add) - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def delete_abort_if_error(to_delete, url): - """ - Deletes the given object from the database - and aborts the request with a 500 error if a SQLAlchemyError occurs. - - Args: - - to_delete: The object to be deleted from the database. - """ - try: - db.session.delete(to_delete) - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def commit_abort_if_error(url): - """ - Commit the current session and handle any SQLAlchemyError that might occur. - """ - try: - db.session.commit() - except SQLAlchemyError as e: - db.session.rollback() - response = json_message(str(e)) - response["url"] = url - abort(500, description=response) - - -def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): - """ - Check if the current user is authorized to appoint new admins to a course. - - Args: - course_id (int): The ID of the course. - - Raises: - HTTPException: If the current user is not authorized or - if the UID of the person to be made an admin is missing in the request body. - """ - url = API_URL + "/courses/" + str(course_id) + "/admins" - abort_if_uid_is_none(teacher, url) - - course = get_course_abort_if_not_found(course_id) - - if teacher != course.teacher: - response = json_message("Only the teacher of a course can appoint new admins") - response["url"] = url - abort(403, description=response) - - if not assistant: - response = json_message( - "uid of person to make admin is required in the request body" - ) - response["url"] = url - abort(400, description=response) - - -def abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids -): - """ - Check the request to assign new students to a course. - - Args: - course_id (int): The ID of the course. - - Raises: - 403: If the user is not authorized to assign new students to the course. - 400: If the request body does not contain the required 'students' field. - """ - url = API_URL + "/courses/" + str(course_id) + "/students" - get_course_abort_if_not_found(course_id) - abort_if_no_user_found_for_uid(uid, url) - query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) - admin_relation = execute_query_abort_if_db_error(query, url) - if not admin_relation: - message = "Not authorized to assign new students to course with id " + str( - course_id - ) - response = json_message(message) - response["url"] = url - abort(403, description=response) - - if not student_uids: - message = """To assign new students to a course, - you should have a students field with a list of uids in the request body""" - response = json_message(message) - response["url"] = url - abort(400, description=response) - - -def abort_if_uid_is_none(uid, url): - """ - Check whether the uid is None if so - abort with error 400 - """ - if uid is None: - response = json_message("There should be a uid in the request query") - response["url"] = url - abort(400, description=response) - - -def abort_if_no_user_found_for_uid(uid, url): - """ - Check if a user exists based on the provided uid. - - Args: - uid (int): The unique identifier of the user. - - Raises: - NotFound: If the user with the given uid is not found. - """ - query = User.query.filter_by(uid=uid) - user = execute_query_abort_if_db_error(query, url) - - if not user: - response = json_message("User with uid " + uid + " was not found") - response["url"] = url - abort(404, description=response) - return user - - -def get_admin_relation(uid, course_id): - """ - Retrieve the CourseAdmin object for the given uid and course. - - Args: - uid (int): The user ID. - course_id (int): The course ID. - - Returns: - CourseAdmin: The CourseAdmin object if the user is an admin, otherwise None. - """ - return execute_query_abort_if_db_error( - CourseAdmin.query.filter_by(uid=uid, course_id=course_id), - url=API_URL + "/courses/" + str(course_id) + "/admins", - ) - - -def json_message(message): - """ - Create a json message with the given message. - - Args: - message (str): The message to include in the json. - - Returns: - dict: The message in a json format. - """ - return {"message": message} - - -def get_course_abort_if_not_found(course_id): - """ - Get a course by its ID. - - Args: - course_id (int): The course ID. - - Returns: - Course: The course with the given ID. - """ - query = Course.query.filter_by(course_id=course_id) - course = execute_query_abort_if_db_error(query, API_URL + "/courses") - - if not course: - response = json_message("Course not found") - response["url"] = API_URL + "/courses" - abort(404, description=response) - - return course - - -class CourseForUser(Resource): - """Api endpoint for the /courses link""" - - def get(self): - """ " - Get function for /courses this will be the main endpoint - to get all courses and filter by given query parameter like /courses?parameter=... - parameters can be either one of the following: teacher,ufora_id,name. - """ - query = Course.query - if "teacher" in request.args: - query = query.filter_by(course_id=request.args.get("teacher")) - if "ufora_id" in request.args: - query = query.filter_by(ufora_id=request.args.get("ufora_id")) - if "name" in request.args: - query = query.filter_by(name=request.args.get("name")) - results:List[Course] = execute_query_abort_if_db_error( - query, url=API_URL + "/courses", query_all=True - ) - courses = [ - {**dataclasses.asdict(course), - "url":f"{API_URL}/courses/{course.course_id}"} - for course in results - ] - message = "Succesfully retrieved all courses with given parameters" - response = json_message(message) - response["data"] = courses - response["url"] = API_URL + "/courses" - return jsonify(response) - - def post(self): - """ - This function will create a new course - if the body of the post contains a name and uid is an admin or teacher - """ - abort_url = API_URL + "/courses" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - user = abort_if_no_user_found_for_uid(uid, abort_url) - - if not user.is_teacher: - message = ( - "Only teachers or admins can create new courses, you are unauthorized" - ) - return json_message(message), 403 - - data = request.get_json() - - if "name" not in data: - message = "Missing 'name' in the request body" - return json_message(message), 400 - - name = data["name"] - new_course = Course(name=name, teacher=uid) - if "ufora_id" in data: - new_course.ufora_id = data["ufora_id"] - - add_abort_if_error(new_course, abort_url) - commit_abort_if_error(abort_url) - - admin_course = CourseAdmin(uid=uid, course_id=new_course.course_id) - add_abort_if_error(admin_course, abort_url) - commit_abort_if_error(abort_url) - - message = (f"Course with name: {name} and" - f"course_id:{new_course.course_id} was succesfully created") - response = json_message(message) - data = { - "course_id": API_URL + "/courses/" + str(new_course.course_id), - "name": new_course.name, - "teacher": API_URL + "/users/" + new_course.teacher, - "ufora_id": new_course.ufora_id if new_course.ufora_id else "None", - } - response["data"] = data - response["url"] = API_URL + "/courses/" + str(new_course.course_id) - return response, 201 - - -class CourseByCourseId(Resource): - """Api endpoint for the /courses/course_id link""" - - def get(self, course_id): - """ - This get function will return all the related projects of the course - in the following form: - { - course: course with course_id - projects: [ - list of all projects that have course_id - where projects are jsons containing the title, deadline and project_id - ] - } - """ - abort_url = API_URL + "/courses" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - admin = get_admin_relation(uid, course_id) - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student = execute_query_abort_if_db_error(query, abort_url) - - if not (admin or student): - message = "User is not an admin, nor a student of this course" - return json_message(message), 404 - - course = get_course_abort_if_not_found(course_id) - query = Project.query.filter_by(course_id=course_id) - abort_url = API_URL + "/courses/" + str(course_id) - # course does exist so url should be to the id - project_uids = [ - API_URL + "/projects/" + project.project_id - for project in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + admin.uid - for admin in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + student.uid - for student in execute_query_abort_if_db_error( - query, abort_url, query_all=True - ) - ] - - data = { - "ufora_id": course.ufora_id, - "teacher": API_URL + "/users/" + course.teacher, - "admins": admin_uids, - "students": student_uids, - "projects": project_uids, - } - response = json_message( - "Succesfully retrieved course with course_id: " + str(course_id) - ) - response["data"] = data - response["url"] = API_URL + "/courses/" + str(course_id) - return response - - def delete(self, course_id): - """ - This function will delete the course with course_id - """ - abort_url = API_URL + "/courses/" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - admin = get_admin_relation(uid, course_id) - - if not admin: - message = "You are not an admin of this course and so you cannot delete it" - return json_message(message), 403 - - course = get_course_abort_if_not_found(course_id) - abort_url = API_URL + "/courses/" + str(course_id) - # course does exist so url should be to the id - delete_abort_if_error(course, abort_url) - commit_abort_if_error(abort_url) - - response = { - "message": "Succesfully deleted course with course_id: " + str(course_id), - "url": API_URL + "/courses", - } - return response - - def patch(self, course_id): - """ - This function will update the course with course_id - """ - abort_url = API_URL + "/courses/" - uid = request.args.get("uid") - abort_if_uid_is_none(uid, abort_url) - - admin = get_admin_relation(uid, course_id) - - if not admin: - message = "You are not an admin of this course and so you cannot update it" - return json_message(message), 403 - - data = request.get_json() - course = get_course_abort_if_not_found(course_id) - abort_url = API_URL + "/courses/" + str(course_id) - if "name" in data: - course.name = data["name"] - if "teacher" in data: - course.teacher = data["teacher"] - if "ufora_id" in data: - course.ufora_id = data["ufora_id"] - - commit_abort_if_error(abort_url) - response = json_message( - "Succesfully updated course with course_id: " + str(course_id) - ) - response["url"] = API_URL + "/courses/" + str(course_id) - data = { - "course_id": API_URL + "/courses/" + str(course.course_id), - "name": course.name, - "teacher": API_URL + "/users/" + course.teacher, - "ufora_id": course.ufora_id if course.ufora_id else "None", - } - response["data"] = data - return response, 200 - - -class CourseForAdmins(Resource): - """ - This class will handle post and delete queries to - the /courses/course_id/admins url, only the teacher of a course can do this - """ - - def get(self, course_id): - """ - This function will return all the admins of a course - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - get_course_abort_if_not_found(course_id) - - query = CourseAdmin.query.filter_by(course_id=course_id) - admin_uids = [ - API_URL + "/users/" + a.uid - for a in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all admins of course " + str(course_id) - ) - response["data"] = admin_uids - response["url"] = abort_url # not actually aborting here tho heheh - return jsonify(admin_uids) - - def post(self, course_id): - """ - Api endpoint for adding new admins to a course, can only be done by the teacher - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - teacher = request.args.get("uid") - data = request.get_json() - assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - - query = User.query.filter_by(uid=assistant) - new_admin = execute_query_abort_if_db_error(query, abort_url) - if not new_admin: - message = ( - "User to make admin was not found, please request with a valid uid" - ) - return json_message(message), 404 - - admin_relation = CourseAdmin(uid=assistant, course_id=course_id) - add_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - response = json_message( - f"Admin assistant added to course {course_id}" - ) - response["url"] = abort_url - data = { - "course_id": API_URL + "/courses/" + str(course_id), - "uid": API_URL + "/users/" + assistant, - } - response["data"] = data - return response, 201 - - def delete(self, course_id): - """ - Api endpoint for removing admins of a course, can only be done by the teacher - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/admins" - teacher = request.args.get("uid") - data = request.get_json() - assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) - - query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) - admin_relation = execute_query_abort_if_db_error(query, abort_url) - if not admin_relation: - message = "Course with given admin not found" - return json_message(message), 404 - - delete_abort_if_error(admin_relation, abort_url) - commit_abort_if_error(abort_url) - - message = ( - f"Admin {assistant}" - f" was succesfully removed from course {course_id}" - ) - response = json_message(message) - response["url"] = abort_url - return response, 204 - - -class CourseToAddStudents(Resource): - """ - Class that will respond to the /courses/course_id/students link - teachers should be able to assign and remove students from courses, - and everyone should be able to list all students assigned to a course - """ - - def get(self, course_id): - """ - Get function at /courses/course_id/students - to get all the users assigned to a course - everyone can get this data so no need to have uid query in the link - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - get_course_abort_if_not_found(course_id) - - query = CourseStudent.query.filter_by(course_id=course_id) - student_uids = [ - API_URL + "/users/" + s.uid - for s in execute_query_abort_if_db_error(query, abort_url, query_all=True) - ] - response = json_message( - "Succesfully retrieved all students of course " + str(course_id) - ) - response["data"] = student_uids - response["url"] = abort_url - return response - - def post(self, course_id): - """ - Allows admins of a course to assign new students by posting to: - /courses/course_id/students with a list of uid in the request body under key "students" - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - uid = request.args.get("uid") - data = request.get_json() - student_uids = data.get("students") - abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids - ) - - for uid in student_uids: - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student_relation = execute_query_abort_if_db_error(query, abort_url) - if student_relation: - db.session.rollback() - message = ( - "Student with uid " + uid + " is already assigned to the course" - ) - return json_message(message), 400 - add_abort_if_error(CourseStudent(uid=uid, course_id=course_id), abort_url) - commit_abort_if_error(abort_url) - response = json_message("User were succesfully added to the course") - response["url"] = abort_url - data = {"students": [API_URL + "/users/" + uid for uid in student_uids]} - response["data"] = data - return response, 201 - - def delete(self, course_id): - """ - This function allows admins of a course to remove students by sending a delete request to - /courses/course_id/students with inside the request body - a field "students" = [list of uids to unassign] - """ - abort_url = API_URL + "/courses/" + str(course_id) + "/students" - uid = request.args.get("uid") - data = request.get_json() - student_uids = data.get("students") - abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids - ) - - for uid in student_uids: - query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) - student_relation = execute_query_abort_if_db_error(query, abort_url) - if student_relation: - delete_abort_if_error(student_relation, abort_url) - commit_abort_if_error(abort_url) - - response = json_message("User were succesfully removed from the course") - response["url"] = API_URL + "/courses/" + str(course_id) + "/students" - return response - - -courses_api.add_resource(CourseForUser, "/courses") - -courses_api.add_resource(CourseByCourseId, "/courses/") - -courses_api.add_resource(CourseForAdmins, "/courses//admins") - -courses_api.add_resource(CourseToAddStudents, "/courses//students") diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py new file mode 100644 index 00000000..c4793a21 --- /dev/null +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -0,0 +1,100 @@ +""" +This module will handle the /courses//admins endpoint +It will allow the teacher of a course to add and remove admins from a course +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource + +from project.models.course_relations import CourseAdmin +from project.models.users import User +from project.endpoints.courses.courses_utils import ( + execute_query_abort_if_db_error, + commit_abort_if_error, + delete_abort_if_error, + get_course_abort_if_not_found, + abort_if_not_teacher_or_none_assistant, + json_message +) +from project.utils.query_agent import query_selected_from_model, insert_into_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseForAdmins(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/admins url, only the teacher of a course can do this + """ + + def get(self, course_id): + """ + This function will return all the admins of a course + """ + abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") + get_course_abort_if_not_found(course_id) + + return query_selected_from_model( + CourseAdmin, + abort_url, + select_values=["uid"], + url_mapper={"uid": urljoin(f"{API_URL}/", "users")}, + filters={"course_id": course_id}, + ) + + def post(self, course_id): + """ + Api endpoint for adding new admins to a course, can only be done by the teacher + """ + abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = User.query.filter_by(uid=assistant) + new_admin = execute_query_abort_if_db_error(query, abort_url) + if not new_admin: + message = ( + "User to make admin was not found, please request with a valid uid" + ) + return json_message(message), 404 + + return insert_into_model( + CourseAdmin, + {"uid": assistant, "course_id": course_id}, + abort_url, + "uid" + ) + + def delete(self, course_id): + """ + Api endpoint for removing admins of a course, can only be done by the teacher + """ + abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") + teacher = request.args.get("uid") + data = request.get_json() + assistant = data.get("admin_uid") + abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + + query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, abort_url) + if not admin_relation: + message = "Course with given admin not found" + return json_message(message), 404 + + delete_abort_if_error(admin_relation, abort_url) + commit_abort_if_error(abort_url) + + message = ( + f"Admin {assistant}" + f" was succesfully removed from course {course_id}" + ) + response = json_message(message) + response["url"] = abort_url + return response, 204 diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py new file mode 100644 index 00000000..605aae01 --- /dev/null +++ b/backend/project/endpoints/courses/course_details.py @@ -0,0 +1,111 @@ +""" +This file contains the api endpoint for the /courses/course_id url +This file is responsible for handling the requests made to the /courses/course_id url +and returning the appropriate response as well as handling the requests made to the +/courses/course_id/admins and /courses/course_id/students urls +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource +from sqlalchemy.exc import SQLAlchemyError + +from project.models.courses import Course +from project.models.course_relations import CourseAdmin, CourseStudent + +from project import db +from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseByCourseId(Resource): + """Api endpoint for the /courses/course_id link""" + + def get(self, course_id): + """ + This get function will return all the related projects of the course + in the following form: + { + course: course with course_id + projects: [ + list of all projects that have course_id + where projects are jsons containing the title, deadline and project_id + ] + } + """ + try: + course_details = db.session.query( + Course.course_id, + Course.name, + Course.ufora_id, + Course.teacher + ).filter( + Course.course_id == course_id).first() + + if not course_details: + return { + "message": "Course not found", + "url": RESPONSE_URL + }, 404 + + admins = db.session.query(CourseAdmin.uid).filter( + CourseAdmin.course_id == course_id + ).all() + + students = db.session.query(CourseStudent.uid).filter( + CourseStudent.course_id == course_id + ).all() + + user_url = urljoin(API_URL + "/", "users") + + admin_ids = [ urljoin(f"{user_url}/" , admin[0]) for admin in admins] + student_ids = [ urljoin(f"{user_url}/", student[0]) for student in students] + + result = { + 'course_id': course_details.course_id, + 'name': course_details.name, + 'ufora_id': course_details.ufora_id, + 'teacher': course_details.teacher, + 'admins': admin_ids, + 'students': student_ids + } + + return { + "message": f"Succesfully retrieved course with course_id: {str(course_id)}", + "data": result, + "url": urljoin(f"{RESPONSE_URL}/", str(course_id)) + } + except SQLAlchemyError: + return { + "error": "Something went wrong while querying the database.", + "url": RESPONSE_URL}, 500 + + def delete(self, course_id): + """ + This function will delete the course with course_id + """ + return delete_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL + ) + + def patch(self, course_id): + """ + This function will update the course with course_id + """ + + return patch_by_id_from_model( + Course, + "course_id", + course_id, + RESPONSE_URL, + request.json + ) diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py new file mode 100644 index 00000000..4a5a6a55 --- /dev/null +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -0,0 +1,111 @@ +""" +This file contains the class CourseToAddStudents which is a +resource for the /courses/course_id/students link. +This class will allow admins of a course to assign and remove students from courses, +and everyone should be able to list all students assigned to a course. +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource + +from project import db +from project.models.course_relations import CourseStudent +from project.endpoints.courses.courses_utils import ( + execute_query_abort_if_db_error, + add_abort_if_error, + commit_abort_if_error, + delete_abort_if_error, + get_course_abort_if_not_found, + abort_if_none_uid_student_uids_or_non_existant_course_id, + json_message, +) + +from project.utils.query_agent import query_selected_from_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseToAddStudents(Resource): + """ + Class that will respond to the /courses/course_id/students link + teachers should be able to assign and remove students from courses, + and everyone should be able to list all students assigned to a course + """ + + def get(self, course_id): + """ + Get function at /courses/course_id/students + to get all the users assigned to a course + everyone can get this data so no need to have uid query in the link + """ + abort_url = f"{API_URL}/courses/{str(course_id)}/students" + get_course_abort_if_not_found(course_id) + + return query_selected_from_model( + CourseStudent, + abort_url, + select_values=["uid"], + url_mapper={"uid": urljoin(f"{API_URL}/", "users")}, + filters={"course_id": course_id} + ) + + def post(self, course_id): + """ + Allows admins of a course to assign new students by posting to: + /courses/course_id/students with a list of uid in the request body under key "students" + """ + abort_url = f"{API_URL}/courses/{str(course_id)}/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + db.session.rollback() + message = ( + f"Student with uid {uid} is already assigned to the course" + ) + return json_message(message), 400 + add_abort_if_error(CourseStudent(uid=uid, course_id=course_id), abort_url) + commit_abort_if_error(abort_url) + response = json_message("Users were succesfully added to the course") + response["url"] = abort_url + data = {"students": [f"{API_URL}/users/{uid}" for uid in student_uids]} + response["data"] = data + return response, 201 + + def delete(self, course_id): + """ + This function allows admins of a course to remove students by sending a delete request to + /courses/course_id/students with inside the request body + a field "students" = [list of uids to unassign] + """ + abort_url = f"{API_URL}/courses/{str(course_id)}/students" + uid = request.args.get("uid") + data = request.get_json() + student_uids = data.get("students") + abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids + ) + + for uid in student_uids: + query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) + student_relation = execute_query_abort_if_db_error(query, abort_url) + if student_relation: + delete_abort_if_error(student_relation, abort_url) + commit_abort_if_error(abort_url) + + response = json_message("Users were succesfully removed from the course") + response["url"] = f"{API_URL}/courses/{str(course_id)}/students" + return response diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py new file mode 100644 index 00000000..c06d7dfc --- /dev/null +++ b/backend/project/endpoints/courses/courses.py @@ -0,0 +1,51 @@ +""" +This file contains the main endpoint for the /courses url. +This endpoint is used to get all courses and filter by given +query parameter like /courses?parameter=... +parameters can be either one of the following: teacher,ufora_id,name. +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask import request +from flask_restful import Resource + +from project.models.courses import Course +from project.utils.query_agent import query_selected_from_model, insert_into_model + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +class CourseForUser(Resource): + """Api endpoint for the /courses link""" + + def get(self): + """ " + Get function for /courses this will be the main endpoint + to get all courses and filter by given query parameter like /courses?parameter=... + parameters can be either one of the following: teacher,ufora_id,name. + """ + + return query_selected_from_model( + Course, + RESPONSE_URL, + url_mapper={"course_id": RESPONSE_URL}, + filters=request.args + ) + + def post(self): + """ + This function will create a new course + if the body of the post contains a name and uid is an admin or teacher + """ + + return insert_into_model( + Course, + request.json, + RESPONSE_URL, + "course_id", + required_fields=["name", "teacher"] + ) diff --git a/backend/project/endpoints/courses/courses_config.py b/backend/project/endpoints/courses/courses_config.py new file mode 100644 index 00000000..f791031f --- /dev/null +++ b/backend/project/endpoints/courses/courses_config.py @@ -0,0 +1,32 @@ +""" +This file is used to configure the courses blueprint and the courses api. +It is used to define the routes for the courses blueprint and the +corresponding api endpoints. + +The courses blueprint is used to define the routes for the courses api +endpoints and the courses api is used to define the routes for the courses +api endpoints. +""" + +from flask import Blueprint +from flask_restful import Api + +from project.endpoints.courses.courses import CourseForUser +from project.endpoints.courses.course_details import CourseByCourseId +from project.endpoints.courses.course_admin_relation import CourseForAdmins +from project.endpoints.courses.course_student_relation import CourseToAddStudents + +courses_bp = Blueprint("courses", __name__) +courses_api = Api(courses_bp) + +courses_bp.add_url_rule("/courses", + view_func=CourseForUser.as_view('course_endpoint')) + +courses_bp.add_url_rule("/courses/", + view_func=CourseByCourseId.as_view('course_by_course_id')) + +courses_bp.add_url_rule("/courses//admins", + view_func=CourseForAdmins.as_view('course_admins')) + +courses_bp.add_url_rule("/courses//students", + view_func=CourseToAddStudents.as_view('course_students')) diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py new file mode 100644 index 00000000..747bc3c2 --- /dev/null +++ b/backend/project/endpoints/courses/courses_utils.py @@ -0,0 +1,234 @@ +""" +This module contains utility functions for the courses endpoints. +The functions are used to interact with the database and handle errors. +""" + +from os import getenv +from urllib.parse import urljoin + +from dotenv import load_dotenv +from flask import abort +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.course_relations import CourseAdmin +from project.models.users import User +from project.models.courses import Course + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(API_URL + "/", "courses") + +def execute_query_abort_if_db_error(query, url, query_all=False): + """ + Execute the given SQLAlchemy query and handle any SQLAlchemyError that might occur. + If query_all == True, the query will be executed with the all() method, + otherwise with the first() method. + Args: + query (Query): The SQLAlchemy query to execute. + + Returns: + ResultProxy: The result of the query if successful, otherwise aborts with error 500. + """ + try: + if query_all: + result = query.all() + else: + result = query.first() + except SQLAlchemyError as e: + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + return result + + +def add_abort_if_error(to_add, url): + """ + Add a new object to the database + and handle any SQLAlchemyError that might occur. + + Args: + to_add (object): The object to add to the database. + """ + try: + db.session.add(to_add) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def delete_abort_if_error(to_delete, url): + """ + Deletes the given object from the database + and aborts the request with a 500 error if a SQLAlchemyError occurs. + + Args: + - to_delete: The object to be deleted from the database. + """ + try: + db.session.delete(to_delete) + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def commit_abort_if_error(url): + """ + Commit the current session and handle any SQLAlchemyError that might occur. + """ + try: + db.session.commit() + except SQLAlchemyError as e: + db.session.rollback() + response = json_message(str(e)) + response["url"] = url + abort(500, description=response) + + +def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): + """ + Check if the current user is authorized to appoint new admins to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + HTTPException: If the current user is not authorized or + if the UID of the person to be made an admin is missing in the request body. + """ + url = f"{API_URL}/courses/{str(course_id)}/admins" + abort_if_uid_is_none(teacher, url) + + course = get_course_abort_if_not_found(course_id) + + if teacher != course.teacher: + response = json_message("Only the teacher of a course can appoint new admins") + response["url"] = url + abort(403, description=response) + + if not assistant: + response = json_message( + "uid of person to make admin is required in the request body" + ) + response["url"] = url + abort(400, description=response) + + +def abort_if_none_uid_student_uids_or_non_existant_course_id( + course_id, uid, student_uids +): + """ + Check the request to assign new students to a course. + + Args: + course_id (int): The ID of the course. + + Raises: + 403: If the user is not authorized to assign new students to the course. + 400: If the request body does not contain the required 'students' field. + """ + url = f"{API_URL}/courses/{str(course_id)}/students" + get_course_abort_if_not_found(course_id) + abort_if_no_user_found_for_uid(uid, url) + query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) + admin_relation = execute_query_abort_if_db_error(query, url) + if not admin_relation: + message = "Not authorized to assign new students to course with id " + str( + course_id + ) + response = json_message(message) + response["url"] = url + abort(403, description=response) + + if not student_uids: + message = """To assign new students to a course, + you should have a students field with a list of uids in the request body""" + response = json_message(message) + response["url"] = url + abort(400, description=response) + + +def abort_if_uid_is_none(uid, url): + """ + Check whether the uid is None if so + abort with error 400 + """ + if uid is None: + response = json_message("There should be a uid in the request query") + response["url"] = url + abort(400, description=response) + + +def abort_if_no_user_found_for_uid(uid, url): + """ + Check if a user exists based on the provided uid. + + Args: + uid (int): The unique identifier of the user. + + Raises: + NotFound: If the user with the given uid is not found. + """ + query = User.query.filter_by(uid=uid) + user = execute_query_abort_if_db_error(query, url) + + if not user: + response = json_message(f"User with uid {uid} was not found") + response["url"] = url + abort(404, description=response) + return user + + +def get_admin_relation(uid, course_id): + """ + Retrieve the CourseAdmin object for the given uid and course. + + Args: + uid (int): The user ID. + course_id (int): The course ID. + + Returns: + CourseAdmin: The CourseAdmin object if the user is an admin, otherwise None. + """ + return execute_query_abort_if_db_error( + CourseAdmin.query.filter_by(uid=uid, course_id=course_id), + url=f"{API_URL}/courses/{str(course_id)}/admins", + ) + + +def json_message(message): + """ + Create a json message with the given message. + + Args: + message (str): The message to include in the json. + + Returns: + dict: The message in a json format. + """ + return {"message": message} + + +def get_course_abort_if_not_found(course_id): + """ + Get a course by its ID. + + Args: + course_id (int): The course ID. + + Returns: + Course: The course with the given ID. + """ + query = Course.query.filter_by(course_id=course_id) + course = execute_query_abort_if_db_error(query, f"{API_URL}/courses") + + if not course: + response = json_message("Course not found") + response["url"] = f"{API_URL}/courses" + abort(404, description=response) + + return course diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 88989247..e2314bd9 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -4,18 +4,18 @@ the corresponding project is 1 """ from os import getenv -from dotenv import load_dotenv +from urllib.parse import urljoin -from flask import jsonify -from flask_restful import Resource, abort -from sqlalchemy import exc -from project.endpoints.projects.endpoint_parser import parse_project_params +from flask import request +from flask_restful import Resource -from project import db from project.models.projects import Project +from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ + patch_by_id_from_model + -load_dotenv() API_URL = getenv('API_HOST') +RESPONSE_URL = urljoin(API_URL, "projects") class ProjectDetail(Resource): """ @@ -24,14 +24,6 @@ class ProjectDetail(Resource): for implementing get, delete and put methods """ - def abort_if_not_present(self, project): - """ - Check if the project exists in the database - and if not abort the request and give back a 404 not found - """ - if project is None: - abort(404) - def get(self, project_id): """ Get method for listing a specific project @@ -39,22 +31,11 @@ def get(self, project_id): the id fetched from the url with the reaparse """ - try: - # fetch the project with the id that is specified in the url - project = Project.query.filter_by(project_id=project_id).first() - self.abort_if_not_present(project) - - # return the fetched project and return 200 OK status - return { - "data": jsonify(project).json, - "url": f"{API_URL}/projects/{project_id}", - "message": "Got project successfully" - }, 200 - except exc.SQLAlchemyError: - return { - "message": "Internal server error", - "url": f"{API_URL}/projects/{project_id}" - }, 500 + return query_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL) def patch(self, project_id): """ @@ -62,30 +43,13 @@ def patch(self, project_id): filtered by id of that specific project """ - # get the project that need to be edited - project = Project.query.filter_by(project_id=project_id).first() - - # check which values are not None in the dict - # if it is not None it needs to be modified in the database - - # commit the changes and return the 200 OK code if it succeeds, else 500 - try: - var_dict = parse_project_params() - for key, value in var_dict.items(): - setattr(project, key, value) - db.session.commit() - # get the updated version - return { - "message": f"Succesfully changed project with id: {id}", - "url": f"{API_URL}/projects/{id}", - "data": project - }, 200 - except exc.SQLAlchemyError: - db.session.rollback() - return { - "message": f"Something unexpected happenend when trying to edit project {id}", - "url": f"{API_URL}/projects/{id}" - }, 500 + return patch_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL, + request.json + ) def delete(self, project_id): """ @@ -93,25 +57,8 @@ def delete(self, project_id): done by project id """ - # fetch the project that needs to be removed - deleted_project = Project.query.filter_by(project_id=project_id).first() - - # check if its an existing one - self.abort_if_not_present(deleted_project) - - # if it exists delete it and commit the changes in the database - try: - db.session.delete(deleted_project) - db.session.commit() - - # return 200 if content is deleted succesfully - return { - "message": f"Project with id: {id} deleted successfully", - "url": f"{API_URL}/projects/{id} deleted successfully!", - "data": deleted_project - }, 200 - except exc.SQLAlchemyError: - return { - "message": f"Something unexpected happened when removing project {project_id}", - "url": f"{API_URL}/projects/{id}" - }, 500 + return delete_by_id_from_model( + Project, + "project_id", + project_id, + RESPONSE_URL) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index f444e283..0834988f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -2,18 +2,14 @@ Module that implements the /projects endpoint of the API """ from os import getenv -from dotenv import load_dotenv +from urllib.parse import urljoin -from flask import jsonify +from flask import request from flask_restful import Resource -from sqlalchemy import exc - -from project import db from project.models.projects import Project -from project.endpoints.projects.endpoint_parser import parse_project_params +from project.utils.query_agent import query_selected_from_model, insert_into_model -load_dotenv() API_URL = getenv('API_HOST') class ProjectsEndpoint(Resource): @@ -28,67 +24,23 @@ def get(self): Get method for listing all available projects that are currently in the API """ - try: - projects = Project.query.with_entities( - Project.project_id, - Project.title, - Project.descriptions - ).all() - - results = [{ - "project_id": row[0], - "title": row[1], - "descriptions": row[2] - } for row in projects] - # return all valid entries for a project and return a 200 OK code - return { - "data": results, - "url": f"{API_URL}/projects", - "message": "Projects fetched successfully" - }, 200 - except exc.SQLAlchemyError: - return { - "message": "Something unexpected happenend when trying to get the projects", - "url": f"{API_URL}/projects" - }, 500 + response_url = urljoin(API_URL, "projects") + return query_selected_from_model( + Project, + response_url, + select_values=["project_id", "title", "descriptions"], + url_mapper={"project_id": response_url}, + filters=request.args + ) def post(self): """ Post functionality for project using flask_restfull parse lib """ - args = parse_project_params() - - # create a new project object to add in the API later - new_project = Project( - title=args['title'], - descriptions=args['descriptions'], - assignment_file=args['assignment_file'], - deadline=args['deadline'], - course_id=args['course_id'], - visible_for_students=args['visible_for_students'], - archieved=args['archieved'], - test_path=args['test_path'], - script_name=args['script_name'], - regex_expressions=args['regex_expressions'] - ) - - # add the new project to the database and commit the changes - - try: - db.session.add(new_project) - db.session.commit() - new_project_json = jsonify(new_project).json - return { - "url": f"{API_URL}/projects/{new_project_json['project_id']}", - "message": "Project posted successfully", - "data": new_project_json - }, 201 - except exc.SQLAlchemyError: - return ({ - "url": f"{API_URL}/projects", - "message": "Something unexpected happenend when trying to add a new project", - "data": jsonify(new_project).json - }, 500) + return insert_into_model( + Project,request.json, + urljoin(API_URL, "/projects"), + "project_id") diff --git a/backend/project/models/courses.py b/backend/project/models/courses.py index 052e5d5f..8d3f0651 100644 --- a/backend/project/models/courses.py +++ b/backend/project/models/courses.py @@ -1,9 +1,9 @@ """The Course model""" + from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String from project import db - @dataclass class Course(db.Model): """This class described the courses table, diff --git a/backend/project/sessionmaker.py b/backend/project/sessionmaker.py index 9fbf1cad..0ab68f8e 100644 --- a/backend/project/sessionmaker.py +++ b/backend/project/sessionmaker.py @@ -1,11 +1,8 @@ """initialise a datab session""" from os import getenv -from dotenv import load_dotenv from sqlalchemy import create_engine, URL from sqlalchemy.orm import sessionmaker -load_dotenv() - url = URL.create( drivername="postgresql", username=getenv("POSTGRES_USER"), diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py new file mode 100644 index 00000000..2995f8de --- /dev/null +++ b/backend/project/utils/misc.py @@ -0,0 +1,76 @@ +""" +This module contains miscellaneous utility functions. +These functions apply to a variety of use cases and are not specific to any one module. +""" + +from typing import Dict, List +from urllib.parse import urljoin +from sqlalchemy.ext.declarative import DeclarativeMeta + + +def map_keys_to_url(url_mapper: Dict[str, str], data: Dict[str, str]) -> Dict[str, str]: + """ + Maps keys to a url using a url mapper. + + Args: + url_mapper: Dict[str, str] - A dictionary that maps keys to urls. + data: Dict[str, str] - The data to map to urls. + + Returns: + A dictionary with the keys mapped to the urls. + """ + for key, value in data.items(): + if key in url_mapper: + data[key] = urljoin(url_mapper[key] + "/", str(value)) + return data + +def map_all_keys_to_url(url_mapper: Dict[str, str], data: List[Dict[str, str]]): + """ + Maps all keys to a url using a url mapper. + + Args: + url_mapper: Dict[str, str] - A dictionary that maps keys to urls. + data: List[Dict[str, str]] - The data to map to urls. + + Returns: + A list of dictionaries with the keys mapped to the urls. + """ + return [map_keys_to_url(url_mapper, entry) for entry in data] + +def model_to_dict(instance: DeclarativeMeta) -> Dict[str, str]: + """ + Converts an sqlalchemy model to a dictionary. + + Args: + instance: DeclarativeMeta - The instance of the model to convert to a dictionary. + + Returns: + A dictionary with the keys and values of the model. + """ + return {column.key: getattr(instance, column.key) for column in instance.__table__.columns} + +def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: + """ + Converts a list of sqlalchemy models to a list of dictionaries. + + Args: + instances: List[DeclarativeMeta] - The instances of the models to convert to dictionaries. + + Returns: + A list of dictionaries with the keys and values of the models. + """ + return [model_to_dict(instance) for instance in instances] + + +def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): + """ + Filters the data to only contain the fields of the model. + + Args: + model: DeclarativeMeta - The model to filter the data with. + data: Dict[str, str] - The data to filter. + + Returns: + A dictionary with the fields of the model. + """ + return {key: value for key, value in data.items() if hasattr(model, key)} diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py new file mode 100644 index 00000000..8a688163 --- /dev/null +++ b/backend/project/utils/query_agent.py @@ -0,0 +1,210 @@ +""" +This module contains the functions to interact with the database. It contains functions to +delete, insert and query entries from the database. The functions are used by the routes +to interact with the database. +""" + +from typing import Dict, List, Union +from urllib.parse import urljoin +from flask import jsonify +from sqlalchemy import and_ +from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.orm.query import Query +from sqlalchemy.exc import SQLAlchemyError +from project.db_in import db +from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields + +def delete_by_id_from_model( + model: DeclarativeMeta, + column_name: str, + column_id: int, + base_url: str): + """ + Deletes an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to delete from. + column_name: str - The name of the column to delete from. + id: int - The id of the entry to delete. + + Returns: + A message indicating that the resource was deleted successfully if the operation was + successful, otherwise a message indicating that something went wrong while deleting from + the database. + """ + try: + result: DeclarativeMeta = model.query.filter( + getattr(model, column_name) == column_id + ).first() + + if not result: + return { + "message": "Resource not found", + "url": base_url}, 404 + db.session.delete(result) + db.session.commit() + return {"message": "Resource deleted successfully", + "url": base_url}, 200 + except SQLAlchemyError: + return {"error": "Something went wrong while deleting from the database.", + "url": base_url}, 500 + +def insert_into_model(model: DeclarativeMeta, + data: Dict[str, Union[str, int]], + response_url_base: str, + url_id_field: str, + required_fields: List[str] = None): + """ + Inserts a new entry into the database giving the model corresponding to a certain table + and the data to insert. + + Args: + model: DeclarativeMeta - The model corresponding to the table to insert into. + data: Dict[str, Union[str, int]] - The data to insert into the table. + response_url_base: str - The base url to use in the response. + + Returns: + The new entry inserted into the database if the operation was successful, otherwise + a message indicating that something went wrong while inserting into the database. + """ + try: + if required_fields is None: + required_fields = [] + # Check if all non-nullable fields are present in the data + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + return {"error": f"Missing required fields: {', '.join(missing_fields)}", + "url": response_url_base}, 400 + + filtered_data = filter_model_fields(model, data) + new_instance: DeclarativeMeta = model(**filtered_data) + db.session.add(new_instance) + db.session.commit() + return jsonify({ + "data": new_instance, + "message": "Object created succesfully.", + "url": urljoin( + f"{response_url_base}/", + str(getattr(new_instance, url_id_field)))}), 201 + except SQLAlchemyError: + return jsonify({"error": "Something went wrong while inserting into the database.", + "url": response_url_base}), 500 + +def query_selected_from_model(model: DeclarativeMeta, + response_url: str, + url_mapper: Dict[str, str] = None, + select_values: List[str] = None, + filters: Dict[str, Union[str, int]]=None): + """ + Query entries from the database giving the model corresponding to a certain table and + the filters to apply to the query. + + + Args: + model: DeclarativeMeta - The model corresponding to the table to query from. + response_url: str - The base url to use in the response. + url_mapper: Dict[str, str] - A dictionary to map the keys of the response to urls. + select_values: List[str] - The columns to select from the table. + filters: Dict[str, Union[str, int]] - The filters to apply to the query. + + Returns: + The entries queried from the database if they exist, otherwise a message indicating + that the resource was not found. + """ + try: + query: Query = model.query + if filters: + filtered_filters = filter_model_fields(model, filters) + conditions: List[bool] = [] + for key, value in filtered_filters.items(): + conditions.append(getattr(model, key) == value) + query = query.filter(and_(*conditions)) + + if select_values: + query = query.with_entities(*[getattr(model, value) for value in select_values]) + query_result = query.all() + results = [] + for instance in query_result: + selected_instance = {} + for value in select_values: + selected_instance[value] = getattr(instance, value) + results.append(selected_instance) + else: + results = models_to_dict(query.all()) + if url_mapper: + results = map_all_keys_to_url(url_mapper, results) + response = {"data": results, + "message": "Resources fetched successfully", + "url": response_url} + return jsonify(response), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while querying the database.", + "url": response_url}, 500 + +def query_by_id_from_model(model: DeclarativeMeta, + column_name: str, + column_id: int, + base_url: str): + """ + Query an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to query from. + column_name: str - The name of the column to query from. + id: int - The id of the entry to query. + not_found_message: str - The message to return if the entry is not found. + + Returns: + The entry queried from the database if it exists, otherwise a message indicating + that the resource was not found. + + """ + try: + result: Query = model.query.filter(getattr(model, column_name) == column_id).first() + if not result: + return {"message": "Resource not found", "url": base_url}, 404 + return jsonify({ + "data": result, + "message": "Resource fetched correctly", + "url": urljoin(f"{base_url}/", str(column_id))}), 200 + except SQLAlchemyError: + return { + "error": "Something went wrong while querying the database.", + "url": base_url}, 500 + +def patch_by_id_from_model(model: DeclarativeMeta, + column_name: str, + column_id: int, + base_url: str, + data: Dict[str, Union[str, int]]): + """ + Update an entry from the database giving the model corresponding to a certain table, + a column name and its value. + + Args: + model: DeclarativeMeta - The model corresponding to the table to update. + column_name: str - The name of the column to update. + id: int - The id of the entry to update. + data: Dict[str, Union[str, int]] - The data to update the entry with. + + Returns: + The entry updated from the database if the operation was successful, otherwise + a message indicating that something went wrong while updating the entry. + """ + try: + result: Query = model.query.filter(getattr(model, column_name) == column_id).first() + if not result: + return {"message": "Resource not found", "url": base_url}, 404 + for key, value in data.items(): + setattr(result, key, value) + db.session.commit() + return jsonify({ + "data": result, + "message": "Resource updated successfully", + "url": urljoin(f"{base_url}/", str(column_id))}), 200 + except SQLAlchemyError: + return {"error": "Something went wrong while updating the database.", + "url": base_url}, 500 diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 4df98cd5..ac299f01 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -25,19 +25,6 @@ def test_post_courses(self, courses_init_db, client, course_data, invalid_course assert course is not None assert course.teacher == "Bart" - response = client.post( - "/courses?uid=Jef", json=course_data - ) # non existent user - assert response.status_code == 404 - - response = client.post( - "/courses?uid=student_sel2_0", json=course_data - ) # existent user but no rights - assert response.status_code == 403 - - response = client.post("/courses", json=course_data) # bad link, no uid passed - assert response.status_code == 400 - response = client.post( "/courses?uid=Bart", json=invalid_course ) # invalid course @@ -88,11 +75,6 @@ def test_post_courses_course_id_students_and_admins(self, db_with_course, client sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" - response = client.post( - sel2_admins_link + "?uid=student_sel2_0", # unauthorized user - json={"admin_uid": "Rien"}, - ) - assert response.status_code == 403 course_admins = [ s.uid for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() @@ -131,7 +113,7 @@ def test_get_courses(self, courses_get_db, client, api_url): assert response.status_code == 200 sel2_students = [ - f"{api_url}/users/" + s.uid + {"uid": f"{api_url}/users/" + s.uid} for s in CourseStudent.query.filter_by(course_id=course.course_id).all() ] From 66c74f99eb29e06931a4e13bcaa8cc909211123d Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 9 Mar 2024 11:44:39 +0100 Subject: [PATCH 125/377] requested changes --- backend/project/endpoints/users.py | 58 ++++++++++++---------------- backend/tests/endpoints/user_test.py | 2 +- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 8dd42784..8dbe25d2 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -37,10 +37,11 @@ def get(self): users = query.all() result = jsonify({"message": "Queried all users", "data": users, - "url":f"{API_URL}/users/", "status_code": 200}) + "url":f"{API_URL}/users", "status_code": 200}) return result except SQLAlchemyError: - return {"message": "An error occurred while fetching the users"}, 500 + return {"message": "An error occurred while fetching the users", + "url": f"{API_URL}/users"}, 500 def post(self): """ @@ -54,11 +55,11 @@ def post(self): if is_teacher is None or is_admin is None or uid is None: return { "message": "Invalid request data!", - "Correct Format": { + "correct_format": { "uid": "User ID (string)", "is_teacher": "Teacher status (boolean)", "is_admin": "Admin status (boolean)" - } + },"url": f"{API_URL}/users" }, 400 try: user = db.session.get(userModel, uid) @@ -69,18 +70,14 @@ def post(self): new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) db.session.add(new_user) db.session.commit() - user_js = { - 'uid': user.uid, - 'is_teacher': user.is_teacher, - 'is_admin': user.is_admin - } - return {"message": "User created successfully!", - "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 201 + return jsonify({"message": "User created successfully!", + "data": user, "url": f"{API_URL}/users/{user.uid}", "status_code": 201}) except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"message": "An error occurred while creating the user"}, 500 + return {"message": "An error occurred while creating the user", + "url": f"{API_URL}/users"}, 500 @@ -95,17 +92,13 @@ def get(self, user_id): try: user = db.session.get(userModel, user_id) if user is None: - return {"message": "User not found!"}, 404 - - user_js = { - 'uid': user.uid, - 'is_teacher': user.is_teacher, - 'is_admin': user.is_admin - } - return {"message": "User queried","data":user_js, - "url": f"{API_URL}/users/{user.uid}"}, 200 + return {"message": "User not found!","url": f"{API_URL}/users"}, 404 + + return jsonify({"message": "User queried","data":user, + "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: - return {"message": "An error occurred while fetching the user"}, 500 + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 def patch(self, user_id): """ @@ -120,7 +113,7 @@ def patch(self, user_id): try: user = db.session.get(userModel, user_id) if user is None: - return {"message": "User not found!"}, 404 + return {"message": "User not found!","url": f"{API_URL}/users"}, 404 if is_teacher is not None: user.is_teacher = is_teacher @@ -129,17 +122,13 @@ def patch(self, user_id): # Save the changes to the database db.session.commit() - user_js = { - 'uid': user.uid, - 'is_teacher': user.is_teacher, - 'is_admin': user.is_admin - } - return {"message": "User updated successfully!", - "data": user_js, "url": f"{API_URL}/users/{user.uid}"}, 200 + return jsonify({"message": "User updated successfully!", + "data": user, "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"message": "An error occurred while patching the user"}, 500 + return {"message": "An error occurred while patching the user", + "url": f"{API_URL}/users"}, 500 def delete(self, user_id): @@ -150,15 +139,16 @@ def delete(self, user_id): try: user = db.session.get(userModel, user_id) if user is None: - return {"message": "User not found!"}, 404 + return {"message": "User not found!", "url": f"{API_URL}/users"}, 404 db.session.delete(user) db.session.commit() - return {"message": "User deleted successfully!"}, 200 + return {"message": "User deleted successfully!", "url": f"{API_URL}/users"}, 200 except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() - return {"message": "An error occurred while deleting the user"}, 500 + return {"message": "An error occurred while deleting the user", + "url": f"{API_URL}/users"}, 500 users_api.add_resource(Users, "/users") diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 438bf8c3..f6b76862 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -44,7 +44,7 @@ def test_delete_user(self, client,user_db_session): # Delete the user response = client.delete("/users/del") assert response.status_code == 200 - assert response.json == {"message": "User deleted successfully!"} + assert response.json["message"] == "User deleted successfully!" def test_delete_not_present(self, client,user_db_session): """Test deleting a user that does not exist.""" From 4f3fbdb9e428ec344b7f78f9a2497dc41c12bb84 Mon Sep 17 00:00:00 2001 From: warre Date: Sat, 9 Mar 2024 13:12:13 +0100 Subject: [PATCH 126/377] merge conflict --- backend/project/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 2e2436c0..299412f1 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -7,8 +7,6 @@ from .endpoints.index.index import index_bp from .endpoints.projects.project_endpoint import project_bp from .endpoints.courses.courses_config import courses_bp -from .endpoints.courses import courses_bp - from .endpoints.users import users_bp From ab9777c3859a9aa16018b1953db724e78264bf1d Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 9 Mar 2024 15:56:03 +0100 Subject: [PATCH 127/377] renamed model files to singular (#64) * renamed model files to singular * Fix #63 refactored with singulare module file names * resolved incorrect imports --- .../project/endpoints/courses/course_admin_relation.py | 4 ++-- backend/project/endpoints/courses/course_details.py | 4 ++-- .../project/endpoints/courses/course_student_relation.py | 2 +- backend/project/endpoints/courses/courses.py | 2 +- backend/project/endpoints/courses/courses_utils.py | 6 +++--- backend/project/endpoints/projects/project_detail.py | 2 +- backend/project/endpoints/projects/projects.py | 2 +- backend/project/endpoints/users.py | 2 +- backend/project/models/{courses.py => course.py} | 0 .../models/{course_relations.py => course_relation.py} | 0 backend/project/models/{projects.py => project.py} | 0 backend/project/models/{submissions.py => submission.py} | 0 backend/project/models/{users.py => user.py} | 0 backend/tests/endpoints/conftest.py | 8 ++++---- backend/tests/endpoints/courses_test.py | 4 ++-- backend/tests/endpoints/project_test.py | 2 +- backend/tests/endpoints/user_test.py | 2 +- backend/tests/models/conftest.py | 8 ++++---- backend/tests/models/course_test.py | 6 +++--- backend/tests/models/projects_and_submissions_test.py | 4 ++-- backend/tests/models/users_test.py | 2 +- 21 files changed, 30 insertions(+), 30 deletions(-) rename backend/project/models/{courses.py => course.py} (100%) rename backend/project/models/{course_relations.py => course_relation.py} (100%) rename backend/project/models/{projects.py => project.py} (100%) rename backend/project/models/{submissions.py => submission.py} (100%) rename backend/project/models/{users.py => user.py} (100%) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index c4793a21..cb00cb51 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -10,8 +10,8 @@ from flask import request from flask_restful import Resource -from project.models.course_relations import CourseAdmin -from project.models.users import User +from project.models.course_relation import CourseAdmin +from project.models.user import User from project.endpoints.courses.courses_utils import ( execute_query_abort_if_db_error, commit_abort_if_error, diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 605aae01..e4b4458f 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -14,8 +14,8 @@ from flask_restful import Resource from sqlalchemy.exc import SQLAlchemyError -from project.models.courses import Course -from project.models.course_relations import CourseAdmin, CourseStudent +from project.models.course import Course +from project.models.course_relation import CourseAdmin, CourseStudent from project import db from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 4a5a6a55..b86429f0 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -14,7 +14,7 @@ from flask_restful import Resource from project import db -from project.models.course_relations import CourseStudent +from project.models.course_relation import CourseStudent from project.endpoints.courses.courses_utils import ( execute_query_abort_if_db_error, add_abort_if_error, diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index c06d7dfc..bafd881e 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -12,7 +12,7 @@ from flask import request from flask_restful import Resource -from project.models.courses import Course +from project.models.course import Course from project.utils.query_agent import query_selected_from_model, insert_into_model load_dotenv() diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index 747bc3c2..cc5fdc1e 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -11,9 +11,9 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.course_relations import CourseAdmin -from project.models.users import User -from project.models.courses import Course +from project.models.course_relation import CourseAdmin +from project.models.user import User +from project.models.course import Course load_dotenv() API_URL = getenv("API_HOST") diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index e2314bd9..df4e99d7 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -9,7 +9,7 @@ from flask import request from flask_restful import Resource -from project.models.projects import Project +from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 0834988f..275e21eb 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -7,7 +7,7 @@ from flask import request from flask_restful import Resource -from project.models.projects import Project +from project.models.project import Project from project.utils.query_agent import query_selected_from_model, insert_into_model API_URL = getenv('API_HOST') diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 8dbe25d2..2febaffd 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.users import User as userModel +from project.models.user import User as userModel users_bp = Blueprint("users", __name__) users_api = Api(users_bp) diff --git a/backend/project/models/courses.py b/backend/project/models/course.py similarity index 100% rename from backend/project/models/courses.py rename to backend/project/models/course.py diff --git a/backend/project/models/course_relations.py b/backend/project/models/course_relation.py similarity index 100% rename from backend/project/models/course_relations.py rename to backend/project/models/course_relation.py diff --git a/backend/project/models/projects.py b/backend/project/models/project.py similarity index 100% rename from backend/project/models/projects.py rename to backend/project/models/project.py diff --git a/backend/project/models/submissions.py b/backend/project/models/submission.py similarity index 100% rename from backend/project/models/submissions.py rename to backend/project/models/submission.py diff --git a/backend/project/models/users.py b/backend/project/models/user.py similarity index 100% rename from backend/project/models/users.py rename to backend/project/models/user.py diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 0e964c22..6110ac93 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,10 +3,10 @@ import os import pytest -from project.models.courses import Course -from project.models.users import User -from project.models.projects import Project -from project.models.course_relations import CourseStudent,CourseAdmin +from project.models.course import Course +from project.models.user import User +from project.models.project import Project +from project.models.course_relation import CourseStudent,CourseAdmin from project import create_app_with_db, db from project.db_in import url diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index ac299f01..96b80459 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -1,9 +1,9 @@ """Here we will test all the courses endpoint related functionality""" -from project.models.course_relations import CourseStudent, CourseAdmin +from project.models.course_relation import CourseStudent, CourseAdmin -from project.models.courses import Course +from project.models.course import Course class TestCourseEndpoint: diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 1ebecce4..0ddc96fa 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,5 +1,5 @@ """Tests for project endpoints.""" -from project.models.projects import Project +from project.models.project import Project def test_projects_home(client): """Test home project endpoint.""" diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index f6b76862..98ed1010 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -10,7 +10,7 @@ import pytest from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine -from project.models.users import User +from project.models.user import User from project import db from tests import db_url diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 4ed9bdcf..1a272500 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -6,10 +6,10 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker import pytest -from project.models.courses import Course -from project.models.course_relations import CourseAdmin, CourseStudent -from project.models.projects import Project -from project.models.users import User +from project.models.course import Course +from project.models.course_relation import CourseAdmin, CourseStudent +from project.models.project import Project +from project.models.user import User from project.db_in import url engine = create_engine(url) diff --git a/backend/tests/models/course_test.py b/backend/tests/models/course_test.py index 8f6872ae..6c286d7c 100644 --- a/backend/tests/models/course_test.py +++ b/backend/tests/models/course_test.py @@ -1,9 +1,9 @@ """Test module for the Course model""" import pytest from sqlalchemy.exc import IntegrityError -from project.models.courses import Course -from project.models.users import User -from project.models.course_relations import CourseAdmin, CourseStudent +from project.models.course import Course +from project.models.user import User +from project.models.course_relation import CourseAdmin, CourseStudent class TestCourseModel: diff --git a/backend/tests/models/projects_and_submissions_test.py b/backend/tests/models/projects_and_submissions_test.py index 9c121df6..912d9294 100644 --- a/backend/tests/models/projects_and_submissions_test.py +++ b/backend/tests/models/projects_and_submissions_test.py @@ -2,8 +2,8 @@ from datetime import datetime import pytest from sqlalchemy.exc import IntegrityError -from project.models.projects import Project -from project.models.submissions import Submission +from project.models.project import Project +from project.models.submission import Submission class TestProjectAndSubmissionModel: # pylint: disable=too-few-public-methods """Test class for the database models of projects and submissions""" diff --git a/backend/tests/models/users_test.py b/backend/tests/models/users_test.py index 9cca421d..2d77b953 100644 --- a/backend/tests/models/users_test.py +++ b/backend/tests/models/users_test.py @@ -1,7 +1,7 @@ """ This file contains the tests for the User model. """ -from project.models.users import User +from project.models.user import User class TestUserModel: From 08d997741e066db41e1b43567af06cfa55294d5a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 19:23:44 +0100 Subject: [PATCH 128/377] #15 - Uploading files --- backend/project/__init__.py | 1 + backend/project/endpoints/submissions.py | 32 +++++++- backend/project/utils.py | 48 ++++++++++++ backend/tests/endpoints/conftest.py | 34 ++++++++- backend/tests/endpoints/submissions_test.py | 83 +++++++++++++++++---- 5 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 backend/project/utils.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index f81d3cfa..bcbb7630 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -36,6 +36,7 @@ def create_app_with_db(db_uri: str): app = create_app() app.config["SQLALCHEMY_DATABASE_URI"] = db_uri + app.config["UPLOAD_FOLDER"] = "/" db.init_app(app) return app diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 0f403d02..695f653b 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,7 +2,7 @@ from urllib.parse import urljoin from datetime import datetime -from os import getenv +from os import getenv, path from dotenv import load_dotenv from flask import Blueprint, request from flask_restful import Resource @@ -11,6 +11,7 @@ from project.models.submissions import Submission from project.models.projects import Project from project.models.users import User +from project.utils import check_filename, zip_files load_dotenv() API_HOST = getenv("API_HOST") @@ -96,8 +97,33 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - # Get the files, store them, test them ... - submission.submission_path = "/tbd" + regexes = session.get(Project, int(project_id)).regex_expressions + # Filter out incorrect or empty files + files = list(filter(lambda file: + file and file.filename != "" and path.getsize(file.filename) > 0, + request.files.getlist("files") + )) + + # Filter out files that don't follow the project's regexes + correct_files = list(filter(lambda file: + check_filename(file.filename, regexes), + files + )) + # Return with a bad request and tell which files where invalid + if not correct_files: + incorrect_files = [file.filename for file in files if file not in correct_files] + data["message"] = "No files were uploaded" if not files else \ + f"Invalid filename(s) (filenames={','.join(incorrect_files)})" + data["data"] = incorrect_files + return data, 400 + # Zip the files and save the zip + zip_file = zip_files("", correct_files) + if zip_file is None: + data["message"] = "Something went wrong while zipping the files" + return data, 500 + # FIXME app.config["UPLOAD_FOLDER"] instead of "/" + submission.submission_path = "/zip.zip" + zip_file.save(path.join("/", submission.submission_path)) # Submission status submission.submission_status = False diff --git a/backend/project/utils.py b/backend/project/utils.py new file mode 100644 index 00000000..0a83798d --- /dev/null +++ b/backend/project/utils.py @@ -0,0 +1,48 @@ +"""Utility functions""" + +from re import match +from typing import List +from io import BytesIO +from zipfile import ZipFile, ZIP_DEFLATED +from werkzeug.utils import secure_filename +from werkzeug.datastructures import FileStorage + +def check_filename(filename: str, regexes: List[str]) -> bool: + """Check if the filename + + Args: + filename (str): The filename + regex_list (List[str]): A list of regexes to match against + + Returns: + bool: Is the filename ok + """ + + # Return true if the filename matches for all regexes + return all(map(lambda regex: match(regex, filename) is not None, regexes)) + +def zip_files(name: str, files: List[FileStorage]) -> FileStorage | None: + """Zip a dictionary of files + + Args: + files (List[FileStorage]): The files to be zipped + + Returns: + FileStorage: The zipped file + """ + + compression = ZIP_DEFLATED # Compression algorithm + zip64 = False # Extension for larger files and archives (now limited to 4GB) + level = None # Compression level, None = default + + try: + buffer = BytesIO() + with ZipFile(buffer, "w", compression, zip64, level) as zip_file: + for file in files: + filename = secure_filename(file.filename) + zip_file.writestr(filename, file.stream.read()) + zip_file = FileStorage(buffer, name) + + return zip_file + except IOError: + return None diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index b515caf5..ba214da0 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,5 +1,6 @@ """ Configuration for pytest, Flask, and the test client.""" +import tempfile import os from datetime import datetime import pytest @@ -64,7 +65,7 @@ def projects(courses): archieved=False, test_path="/tests", script_name="script.sh", - regex_expressions=["*"] + regex_expressions=["solution"] ), Project( project_id=2, @@ -77,7 +78,7 @@ def projects(courses): archieved=True, test_path="/tests", script_name="script.sh", - regex_expressions=["*"] + regex_expressions=[".*"] ) ] @@ -116,6 +117,35 @@ def submissions(projects): ) ] +@pytest.fixture +def file_empty(): + """Return an empty file""" + descriptor, name = tempfile.mkstemp() + with open(descriptor, "rb") as temp: + yield temp, name + +@pytest.fixture +def file_no_name(): + """Return a file with no name""" + descriptor, name = tempfile.mkstemp() + with open(descriptor, "w", encoding="UTF-8") as temp: + temp.write("This is a test file.") + with open(name, "rb") as temp: + yield temp, "" + +@pytest.fixture +def files(): + """Return a temporary file""" + descriptor01, name01 = tempfile.mkstemp() + with open(descriptor01, "w", encoding="UTF-8") as temp: + temp.write("This is a test file.") + descriptor02, name02 = tempfile.mkstemp() + with open(descriptor02, "w", encoding="UTF-8") as temp: + temp.write("This is a test file.") + with open(name01, "rb") as temp01: + with open(name02, "rb") as temp02: + yield [(temp01, name01), (temp02, name02)] + engine = create_engine(url) Session = sessionmaker(bind=engine) @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5b057d3c..56159de4 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -76,66 +76,123 @@ def test_get_submissions_user_project(self, client: FlaskClient, session: Sessio ] ### POST SUBMISSIONS ### - def test_post_submissions_no_user(self, client: FlaskClient, session: Session): + def test_post_submissions_no_user(self, client: FlaskClient, session: Session, files): """Test posting a submission without specifying a user""" response = client.post("/submissions", data={ - "project_id": 1 + "project_id": 1, + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The uid data field is required" - def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session): + def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing user""" response = client.post("/submissions", data={ "uid": "unknown", - "project_id": 1 + "project_id": 1, + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid user (uid=unknown)" - def test_post_submissions_no_project(self, client: FlaskClient, session: Session): + def test_post_submissions_no_project(self, client: FlaskClient, session: Session, files): """Test posting a submission without specifying a project""" response = client.post("/submissions", data={ - "uid": "student01" + "uid": "student01", + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The project_id data field is required" - def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session): + def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing project""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": -1 + "project_id": -1, + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=-1)" - def test_post_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + def test_post_submissions_wrong_project_type( + self, client: FlaskClient, session: Session, files + ): """Test posting a submission for a non-existing project of the wrong type""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": "zero" + "project_id": "zero", + "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid project (project_id=zero)" - def test_post_submissions_correct(self, client: FlaskClient, session: Session): - """Test posting a submission""" + def test_post_submissions_no_files(self, client: FlaskClient, session: Session): + """Test posting a submission when no files are uploaded""" response = client.post("/submissions", data={ "uid": "student01", "project_id": 1 }) data = response.json + assert response.status_code == 400 + assert data["message"] == "No files were uploaded" + + def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): + """Test posting a submission for an empty file""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "files": file_empty + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "No files were uploaded" + + def test_post_submissions_file_with_no_name( + self, client: FlaskClient, session: Session, file_no_name + ): + """Test posting a submission for a file without a name""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "files": file_no_name + }) + data = response.json + assert response.status_code == 400 + assert data["message"] == "No files were uploaded" + + def test_post_submissions_file_with_wrong_name( + self, client: FlaskClient, session: Session, files + ): + """Test posting a submissions for a file with a wrong name""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 1, + "files": files + }) + data = response.json + assert response.status_code == 400 + assert "Invalid filename(s)" in data["message"] + + def test_post_submissions_correct( + self, client: FlaskClient, session: Session, files + ): + """Test posting a submission""" + response = client.post("/submissions", data={ + "uid": "student01", + "project_id": 2, + "files": files + }) + data = response.json assert response.status_code == 201 assert data["message"] == "Successfully fetched the submissions" assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" assert data["data"]["user"] == f"{API_HOST}/users/student01" - assert data["data"]["project"] == f"{API_HOST}/projects/1" + assert data["data"]["project"] == f"{API_HOST}/projects/2" ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): From c5281a4a3a7121c61545e4ff4a45e471612a19f0 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 19:32:22 +0100 Subject: [PATCH 129/377] #15 - Forgot to update the API --- backend/project/endpoints/index/OpenAPI_Object.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 50c85f4f..f13fb4a1 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1437,10 +1437,11 @@ "type": "integer", "required": true }, - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 + "files": { + "type": "array", + "items": { + "type": "file" + } } } } @@ -1497,7 +1498,7 @@ } }, "400": { - "description": "An invalid user or project is given", + "description": "An invalid user, project or list of files is given", "content": { "application/json": { "schema": { From 145820db60f170cd255073baf9ff3d95bb1a2447 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 20:09:33 +0100 Subject: [PATCH 130/377] #15 - Moving utils.py to utils/files.py and fixing tests --- backend/project/endpoints/submissions.py | 8 ++++---- backend/project/{utils.py => utils/files.py} | 2 +- backend/tests/endpoints/submissions_test.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) rename backend/project/{utils.py => utils/files.py} (97%) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 695f653b..d39e66ad 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -8,10 +8,10 @@ from flask_restful import Resource from sqlalchemy import exc from project.db_in import db -from project.models.submissions import Submission -from project.models.projects import Project -from project.models.users import User -from project.utils import check_filename, zip_files +from project.models.submission import Submission +from project.models.project import Project +from project.models.user import User +from project.utils.files import check_filename, zip_files load_dotenv() API_HOST = getenv("API_HOST") diff --git a/backend/project/utils.py b/backend/project/utils/files.py similarity index 97% rename from backend/project/utils.py rename to backend/project/utils/files.py index 0a83798d..37a3f03f 100644 --- a/backend/project/utils.py +++ b/backend/project/utils/files.py @@ -1,4 +1,4 @@ -"""Utility functions""" +"""Utility functions for files""" from re import match from typing import List diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 56159de4..9af4d60e 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -3,7 +3,7 @@ from os import getenv from flask.testing import FlaskClient from sqlalchemy.orm import Session -from project.models.submissions import Submission +from project.models.submission import Submission API_HOST = getenv("API_HOST") From f441fcce7d24c8f862f9da2a7ddc65869c3574da Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 9 Mar 2024 20:33:47 +0100 Subject: [PATCH 131/377] #15 - Fixing TypeError in GH tests --- backend/project/utils/files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index 37a3f03f..9afdfee5 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -1,7 +1,7 @@ """Utility functions for files""" from re import match -from typing import List +from typing import List, Union from io import BytesIO from zipfile import ZipFile, ZIP_DEFLATED from werkzeug.utils import secure_filename @@ -21,7 +21,7 @@ def check_filename(filename: str, regexes: List[str]) -> bool: # Return true if the filename matches for all regexes return all(map(lambda regex: match(regex, filename) is not None, regexes)) -def zip_files(name: str, files: List[FileStorage]) -> FileStorage | None: +def zip_files(name: str, files: List[FileStorage]) -> Union[FileStorage, None]: """Zip a dictionary of files Args: From f5a80a6786f7677b00124370fa91634c8c722ad3 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 09:55:53 +0100 Subject: [PATCH 132/377] #15 - Trying to fix GH tests --- backend/tests/endpoints/conftest.py | 44 +++----- backend/tests/endpoints/submissions_test.py | 115 ++++++++++++-------- 2 files changed, 84 insertions(+), 75 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 47dbcf72..c556b9bf 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -14,7 +14,6 @@ from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission -@pytest.fixture def users(): """Return a list of users to populate the database""" return [ @@ -24,19 +23,17 @@ def users(): User(uid="student02", is_admin=False, is_teacher=False) ] -@pytest.fixture def courses(): """Return a list of courses to populate the database""" return [ - Course(course_id=1, name="AD3", teacher="brinkmann"), - Course(course_id=2, name="RAF", teacher="laermans"), + Course(name="AD3", teacher="brinkmann"), + Course(name="RAF", teacher="laermans"), ] -@pytest.fixture -def course_relations(courses): +def course_relations(session): """Returns a list of course relations to populate the database""" - course_id_ad3 = courses[0].course_id - course_id_raf = courses[1].course_id + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id return [ CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), @@ -46,15 +43,13 @@ def course_relations(courses): CourseStudent(course_id=course_id_raf, uid="student02") ] -@pytest.fixture -def projects(courses): +def projects(session): """Return a list of projects to populate the database""" - course_id_ad3 = courses[0].course_id - course_id_raf = courses[1].course_id + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id return [ Project( - project_id=1, title="B+ Trees", descriptions="Implement B+ trees", assignment_file="assignement.pdf", @@ -67,7 +62,6 @@ def projects(courses): regex_expressions=["solution"] ), Project( - project_id=2, title="Predicaten", descriptions="Predicaten project", assignment_file="assignment.pdf", @@ -81,15 +75,13 @@ def projects(courses): ) ] -@pytest.fixture -def submissions(projects): +def submissions(session): """Return a list of submissions to populate the database""" - project_id_ad3 = projects[0].project_id - project_id_raf = projects[1].project_id + project_id_ad3 = session.query(Project).filter_by(title="B+ Trees").first().project_id + project_id_raf = session.query(Project).filter_by(title="Predicaten").first().project_id return [ Submission( - submission_id=1, uid="student01", project_id=project_id_ad3, grading=16, @@ -98,7 +90,6 @@ def submissions(projects): submission_status=True ), Submission( - submission_id=2, uid="student02", project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59), @@ -106,7 +97,6 @@ def submissions(projects): submission_status=False ), Submission( - submission_id=3, uid="student02", project_id=project_id_raf, grading=15, @@ -148,7 +138,7 @@ def files(): engine = create_engine(url) Session = sessionmaker(bind=engine) @pytest.fixture -def session(users,courses,course_relations,projects,submissions): +def session(): """Create a database session for the tests""" # Create all tables and get a session db.metadata.create_all(engine) @@ -156,15 +146,15 @@ def session(users,courses,course_relations,projects,submissions): try: # Populate the database - session.add_all(users) + session.add_all(users()) session.commit() - session.add_all(courses) + session.add_all(courses()) session.commit() - session.add_all(course_relations) + session.add_all(course_relations(session)) session.commit() - session.add_all(projects) + session.add_all(projects(session)) session.commit() - session.add_all(submissions) + session.add_all(submissions(session)) session.commit() # Tests can now use a populated database diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 9af4d60e..d2ee3c0b 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -3,6 +3,7 @@ from os import getenv from flask.testing import FlaskClient from sqlalchemy.orm import Session +from project.models.project import Project from project.models.submission import Submission API_HOST = getenv("API_HOST") @@ -38,11 +39,7 @@ def test_get_submissions_all(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1", - f"{API_HOST}/submissions/2", - f"{API_HOST}/submissions/3" - ] + assert len(data["data"]) == 3 def test_get_submissions_user(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user""" @@ -50,36 +47,32 @@ def test_get_submissions_user(self, client: FlaskClient, session: Session): data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1" - ] + assert len(data["data"]) == 1 def test_get_submissions_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific project""" - response = client.get("/submissions?project_id=1") + project = session.query(Project).filter_by(title="B+ Trees").first() + response = client.get(f"/submissions?project_id={project.project_id}") data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1", - f"{API_HOST}/submissions/2" - ] + assert len(data["data"]) == 2 def test_get_submissions_user_project(self, client: FlaskClient, session: Session): """Test getting the submissions given a specific user and project""" - response = client.get("/submissions?uid=student01&project_id=1") + project = session.query(Project).filter_by(title="B+ Trees").first() + response = client.get(f"/submissions?uid=student01&project_id={project.project_id}") data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submissions" - assert data["data"] == [ - f"{API_HOST}/submissions/1" - ] + assert len(data["data"]) == 1 ### POST SUBMISSIONS ### def test_post_submissions_no_user(self, client: FlaskClient, session: Session, files): """Test posting a submission without specifying a user""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ - "project_id": 1, + "project_id": project.project_id, "files": files }) data = response.json @@ -88,9 +81,10 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session, f def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing user""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "unknown", - "project_id": 1, + "project_id": project.project_id, "files": files }) data = response.json @@ -133,9 +127,10 @@ def test_post_submissions_wrong_project_type( def test_post_submissions_no_files(self, client: FlaskClient, session: Session): """Test posting a submission when no files are uploaded""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1 + "project_id": project.project_id }) data = response.json assert response.status_code == 400 @@ -143,9 +138,10 @@ def test_post_submissions_no_files(self, client: FlaskClient, session: Session): def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): """Test posting a submission for an empty file""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, + "project_id": project.project_id, "files": file_empty }) data = response.json @@ -156,9 +152,10 @@ def test_post_submissions_file_with_no_name( self, client: FlaskClient, session: Session, file_no_name ): """Test posting a submission for a file without a name""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, + "project_id": project.project_id, "files": file_no_name }) data = response.json @@ -169,9 +166,10 @@ def test_post_submissions_file_with_wrong_name( self, client: FlaskClient, session: Session, files ): """Test posting a submissions for a file with a wrong name""" + project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "student01", - "project_id": 1, + "project_id": project.project_id, "files": files }) data = response.json @@ -182,36 +180,41 @@ def test_post_submissions_correct( self, client: FlaskClient, session: Session, files ): """Test posting a submission""" + project = session.query(Project).filter_by(title="Predicaten").first() response = client.post("/submissions", data={ - "uid": "student01", - "project_id": 2, + "uid": "student02", + "project_id": project.project_id, "files": files }) data = response.json assert response.status_code == 201 assert data["message"] == "Successfully fetched the submissions" assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" - assert data["data"]["user"] == f"{API_HOST}/users/student01" - assert data["data"]["project"] == f"{API_HOST}/projects/2" + assert data["data"]["user"] == f"{API_HOST}/users/student02" + assert data["data"]["project"] == f"{API_HOST}/projects/{project.project_id}" ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - response = client.get("/submissions/100") + response = client.get("/submissions/0") data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=100) not found" + assert data["message"] == "Submission (submission_id=0) not found" def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" - response = client.get("/submissions/1") + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student01", project_id=project.project_id + ).first() + response = client.get(f"/submissions/{submission.submission_id}") data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" assert data["data"] == { - "id": 1, + "id": submission.submission_id, "user": f"{API_HOST}/users/student01", - "project": f"{API_HOST}/projects/1", + "project": f"{API_HOST}/projects/{project.project_id}", "grading": 16, "time": "Thu, 14 Mar 2024 11:00:00 GMT", "path": "/submissions/1", @@ -221,36 +224,48 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - response = client.patch("/submissions/100", data={"grading": 20}) + response = client.patch("/submissions/0", data={"grading": 20}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=100) not found" + assert data["message"] == "Submission (submission_id=0) not found" def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" - response = client.patch("/submissions/2", data={"grading": 100}) + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student02", project_id=project.project_id + ).first() + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading type""" - response = client.patch("/submissions/2", data={"grading": "zero"}) + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student02", project_id=project.project_id + ).first() + response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" def test_patch_submission_correct(self, client: FlaskClient, session: Session): """Test patching a submission""" - response = client.patch("/submissions/2", data={"grading": 20}) + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student02", project_id=project.project_id + ).first() + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}) data = response.json assert response.status_code == 200 - assert data["message"] == "Submission (submission_id=2) patched" - assert data["url"] == f"{API_HOST}/submissions/2" + assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" + assert data["url"] == f"{API_HOST}/submissions/{submission.submission_id}" assert data["data"] == { - "id": 2, + "id": submission.submission_id, "user": f"{API_HOST}/users/student02", - "project": f"{API_HOST}/projects/1", + "project": f"{API_HOST}/projects/{project.project_id}", "grading": 20, "time": 'Thu, 14 Mar 2024 22:59:59 GMT', "path": "/submissions/2", @@ -260,17 +275,21 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - response = client.delete("submissions/100") + response = client.delete("submissions/0") data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=100) not found" + assert data["message"] == "Submission (submission_id=0) not found" def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" - response = client.delete("submissions/1") + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = session.query(Submission).filter_by( + uid="student01", project_id=project.project_id + ).first() + response = client.delete(f"submissions/{submission.submission_id}") data = response.json assert response.status_code == 200 - assert data["message"] == "Submission (submission_id=1) deleted" - - submission = session.get(Submission, 1) - assert submission is None + assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" + assert submission.submission_id not in list(map( + lambda s: s.submission_id, session.query(Submission).all() + )) From 1c45ebb91b7e58b89d24343bb8fb111f1a5bcbd8 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 10:19:17 +0100 Subject: [PATCH 133/377] #15 - Fixing datetime issues in tests --- backend/tests/endpoints/conftest.py | 7 ++++--- backend/tests/endpoints/submissions_test.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index c556b9bf..1b8f1a1e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,6 +3,7 @@ import tempfile import os from datetime import datetime +from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -85,14 +86,14 @@ def submissions(session): uid="student01", project_id=project_id_ad3, grading=16, - submission_time=datetime(2024,3,14,12,0,0), + submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/1", submission_status=True ), Submission( uid="student02", project_id=project_id_ad3, - submission_time=datetime(2024,3,14,23,59,59), + submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/2", submission_status=False ), @@ -100,7 +101,7 @@ def submissions(session): uid="student02", project_id=project_id_raf, grading=15, - submission_time=datetime(2023,3,5,10,0,0), + submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/3", submission_status=True ) diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index d2ee3c0b..d12c3d7d 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -216,7 +216,7 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): "user": f"{API_HOST}/users/student01", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 16, - "time": "Thu, 14 Mar 2024 11:00:00 GMT", + "time": "Thu, 14 Mar 2024 12:00:00 GMT", "path": "/submissions/1", "status": True } @@ -267,7 +267,7 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): "user": f"{API_HOST}/users/student02", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 20, - "time": 'Thu, 14 Mar 2024 22:59:59 GMT', + "time": 'Thu, 14 Mar 2024 23:59:59 GMT', "path": "/submissions/2", "status": False } From c388b14c131012a0015066afacb9298a70b8ae1a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 10:27:21 +0100 Subject: [PATCH 134/377] #15 - Updating urljoin --- backend/project/endpoints/submissions.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index d39e66ad..8b7f002f 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -53,7 +53,7 @@ def get(self) -> dict[str, any]: # Get the submissions data["message"] = "Successfully fetched the submissions" data["data"] = [ - urljoin(API_HOST, f"/submissions/{s.submission_id}") for s in query.all() + urljoin(f"{API_HOST}/", f"/submissions/{s.submission_id}") for s in query.all() ] return data, 200 @@ -132,11 +132,11 @@ def post(self) -> dict[str, any]: session.commit() data["message"] = "Successfully fetched the submissions" - data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") + data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": urljoin(API_HOST, f"/users/{submission.uid}"), - "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), + "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -173,8 +173,8 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = "Successfully fetched the submission" data["data"] = { "id": submission.submission_id, - "user": urljoin(API_HOST, f"/users/{submission.uid}"), - "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), + "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, @@ -218,11 +218,11 @@ def patch(self, submission_id:int) -> dict[str, any]: session.commit() data["message"] = f"Submission (submission_id={submission_id}) patched" - data["url"] = urljoin(API_HOST, f"/submissions/{submission.submission_id}") + data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { "id": submission.submission_id, - "user": urljoin(API_HOST, f"/users/{submission.uid}"), - "project": urljoin(API_HOST, f"/projects/{submission.project_id}"), + "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, "path": submission.submission_path, From 8975fae3af8cf9e194703d8c88aea9d05673385a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:07:00 +0100 Subject: [PATCH 135/377] #15 - Fixing missing url field --- .../endpoints/index/OpenAPI_Object.json | 68 +++++++++++++++++-- backend/project/endpoints/submissions.py | 21 ++++-- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 3d3fd2f6..3b70d27d 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1570,6 +1570,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, @@ -1595,6 +1599,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1610,6 +1618,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1656,13 +1668,13 @@ "schema": { "type": "object", "properties": { - "message": { - "type": "string" - }, "url": { "type": "string", "format": "uri" }, + "message": { + "type": "string" + }, "data": { "type": "object", "properties": { @@ -1704,6 +1716,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1719,6 +1735,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1741,6 +1761,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" }, @@ -1791,6 +1815,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1806,6 +1834,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1843,13 +1875,13 @@ "schema": { "type": "object", "properties": { - "message": { - "type": "string" - }, "url": { "type": "string", "format": "uri" }, + "message": { + "type": "string" + }, "data": { "type": "object", "properties": { @@ -1891,6 +1923,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1906,6 +1942,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1921,6 +1961,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1941,6 +1985,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1956,6 +2004,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } @@ -1971,6 +2023,10 @@ "schema": { "type": "object", "properties": { + "url": { + "type": "string", + "format": "uri" + }, "message": { "type": "string" } diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 8b7f002f..9dc49442 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -28,7 +28,9 @@ def get(self) -> dict[str, any]: dict[str, any]: The list of submission URLs """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", "/submissions") + } try: with db.session() as session: query = session.query(Submission) @@ -68,7 +70,9 @@ def post(self) -> dict[str, any]: dict[str, any]: The URL to the submission """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", "/submissions") + } try: with db.session() as session: submission = Submission() @@ -114,7 +118,6 @@ def post(self) -> dict[str, any]: incorrect_files = [file.filename for file in files if file not in correct_files] data["message"] = "No files were uploaded" if not files else \ f"Invalid filename(s) (filenames={','.join(incorrect_files)})" - data["data"] = incorrect_files return data, 400 # Zip the files and save the zip zip_file = zip_files("", correct_files) @@ -162,7 +165,9 @@ def get(self, submission_id: int) -> dict[str, any]: dict[str, any]: The submission """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + } try: with db.session() as session: submission = session.get(Submission, submission_id) @@ -197,7 +202,9 @@ def patch(self, submission_id:int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + } try: with db.session() as session: # Get the submission @@ -246,7 +253,9 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = {} + data = { + "url": urljoin(f"{API_HOST}/", "/submissions") + } try: with db.session() as session: submission = session.get(Submission, submission_id) From 9cf0014ca08b94d0fc40959e6eee92c3d3b53d43 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:13:20 +0100 Subject: [PATCH 136/377] #15 - Fixing multiple querying, still linter issues, but deal with that later --- backend/project/endpoints/submissions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 9dc49442..ac2c0394 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -92,7 +92,11 @@ def post(self) -> dict[str, any]: if project_id is None: data["message"] = "The project_id data field is required" return data, 400 - if not project_id.isdigit() or session.get(Project, int(project_id)) is None: + if not project_id.isdigit(): + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 + project = session.get(Project, int(project_id)) + if project is None: data["message"] = f"Invalid project (project_id={project_id})" return data, 400 submission.project_id = int(project_id) @@ -101,7 +105,7 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - regexes = session.get(Project, int(project_id)).regex_expressions + regexes = project.regex_expressions # Filter out incorrect or empty files files = list(filter(lambda file: file and file.filename != "" and path.getsize(file.filename) > 0, From 3da63b4d7378f50552407cf929fda99afa8c4da7 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:33:26 +0100 Subject: [PATCH 137/377] #15 - Updating checking required files --- backend/project/endpoints/submissions.py | 22 +++++++++------------ backend/project/utils/files.py | 19 ++++++++++++++++++ backend/tests/endpoints/submissions_test.py | 4 ++-- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index ac2c0394..46d1fb10 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -11,7 +11,7 @@ from project.models.submission import Submission from project.models.project import Project from project.models.user import User -from project.utils.files import check_filename, zip_files +from project.utils.files import all_files_uploaded, zip_files load_dotenv() API_HOST = getenv("API_HOST") @@ -105,26 +105,22 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - regexes = project.regex_expressions # Filter out incorrect or empty files files = list(filter(lambda file: file and file.filename != "" and path.getsize(file.filename) > 0, request.files.getlist("files") )) + if not files: + data["message"] = "No files were uploaded" + return data, 400 - # Filter out files that don't follow the project's regexes - correct_files = list(filter(lambda file: - check_filename(file.filename, regexes), - files - )) - # Return with a bad request and tell which files where invalid - if not correct_files: - incorrect_files = [file.filename for file in files if file not in correct_files] - data["message"] = "No files were uploaded" if not files else \ - f"Invalid filename(s) (filenames={','.join(incorrect_files)})" + # Check if all files are uploaded + if not all_files_uploaded(files, project.regex_expressions): + data["message"] = "Not all required files were uploaded" return data, 400 + # Zip the files and save the zip - zip_file = zip_files("", correct_files) + zip_file = zip_files("", files) if zip_file is None: data["message"] = "Something went wrong while zipping the files" return data, 500 diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index 9afdfee5..2b440287 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -21,6 +21,25 @@ def check_filename(filename: str, regexes: List[str]) -> bool: # Return true if the filename matches for all regexes return all(map(lambda regex: match(regex, filename) is not None, regexes)) +def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: + """Check if all the required files are uploaded + + Args: + files (List[FileStorage]): The files uploaded + regexes (List[str]): The list of regexes to match against + + Returns: + bool: Are all required files uploaded + """ + + all_uploaded = True + for regex in regexes: + match_found = any(match(regex, file.filename) is not None for file in files) + if not match_found: + all_uploaded = False + break + return all_uploaded + def zip_files(name: str, files: List[FileStorage]) -> Union[FileStorage, None]: """Zip a dictionary of files diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index d12c3d7d..c99ac82d 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -162,7 +162,7 @@ def test_post_submissions_file_with_no_name( assert response.status_code == 400 assert data["message"] == "No files were uploaded" - def test_post_submissions_file_with_wrong_name( + def test_post_submissions_missing_required_files( self, client: FlaskClient, session: Session, files ): """Test posting a submissions for a file with a wrong name""" @@ -174,7 +174,7 @@ def test_post_submissions_file_with_wrong_name( }) data = response.json assert response.status_code == 400 - assert "Invalid filename(s)" in data["message"] + assert data["message"] == "Not all required files were uploaded" def test_post_submissions_correct( self, client: FlaskClient, session: Session, files From ee2e668bf90dea3acb43f8e7d88c8d89149f6491 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:34:19 +0100 Subject: [PATCH 138/377] #15 - Removing unused function --- backend/project/utils/files.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index 2b440287..d38ad3db 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -7,20 +7,6 @@ from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage -def check_filename(filename: str, regexes: List[str]) -> bool: - """Check if the filename - - Args: - filename (str): The filename - regex_list (List[str]): A list of regexes to match against - - Returns: - bool: Is the filename ok - """ - - # Return true if the filename matches for all regexes - return all(map(lambda regex: match(regex, filename) is not None, regexes)) - def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: """Check if all the required files are uploaded From 0b0a32c948f5c3917816006d7fd1d30729c3e1fd Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:36:21 +0100 Subject: [PATCH 139/377] #15 - Using environment variable UPLOAD_FOLDER --- backend/project/endpoints/submissions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 46d1fb10..4ce8b34b 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -15,6 +15,7 @@ load_dotenv() API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") submissions_bp = Blueprint("submissions", __name__) @@ -124,9 +125,8 @@ def post(self) -> dict[str, any]: if zip_file is None: data["message"] = "Something went wrong while zipping the files" return data, 500 - # FIXME app.config["UPLOAD_FOLDER"] instead of "/" submission.submission_path = "/zip.zip" - zip_file.save(path.join("/", submission.submission_path)) + zip_file.save(path.join(f"{UPLOAD_FOLDER}/", submission.submission_path)) # Submission status submission.submission_status = False From f40fc46b63f78594c374f4874806f78e98eb8cb6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 11:39:21 +0100 Subject: [PATCH 140/377] #15 - Add static typing to the submissions model --- backend/project/models/submission.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index 1e8987cd..e2309eea 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -17,10 +17,10 @@ class Submission(db.Model): so we can easily present in a list which submission succeeded the automated checks""" __tablename__ = "submissions" - submission_id = Column(Integer, primary_key=True) - uid = Column(String(255), ForeignKey("users.uid"), nullable=False) - project_id = Column(Integer, ForeignKey("projects.project_id"), nullable=False) - grading = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) - submission_time = Column(DateTime(timezone=True), nullable=False) - submission_path = Column(String(50), nullable=False) - submission_status = Column(Boolean, nullable=False) + submission_id: int = Column(Integer, primary_key=True) + uid: str = Column(String(255), ForeignKey("users.uid"), nullable=False) + project_id: int = Column(Integer, ForeignKey("projects.project_id"), nullable=False) + grading: int = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) + submission_time: DateTime = Column(DateTime(timezone=True), nullable=False) + submission_path: str = Column(String(50), nullable=False) + submission_status: bool = Column(Boolean, nullable=False) From 93032d0c2a4f8eda7a88a1ffff0d5b3346f36690 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 13:14:53 +0100 Subject: [PATCH 141/377] #15 - Fixing linter --- backend/project/endpoints/submissions.py | 41 +++++---------- backend/project/utils/files.py | 15 ++++++ backend/project/utils/project.py | 27 ++++++++++ backend/project/utils/user.py | 26 +++++++++ backend/tests/conftest.py | 20 ++++--- backend/tests/endpoints/conftest.py | 58 +++++---------------- backend/tests/endpoints/submissions_test.py | 10 ++-- 7 files changed, 114 insertions(+), 83 deletions(-) create mode 100644 backend/project/utils/project.py create mode 100644 backend/project/utils/user.py diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 4ce8b34b..b7045501 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -11,7 +11,9 @@ from project.models.submission import Submission from project.models.project import Project from project.models.user import User -from project.utils.files import all_files_uploaded, zip_files +from project.utils.files import filter_files, all_files_uploaded, zip_files +from project.utils.user import is_valid_user +from project.utils.project import is_valid_project load_dotenv() API_HOST = getenv("API_HOST") @@ -80,25 +82,17 @@ def post(self) -> dict[str, any]: # User uid = request.form.get("uid") - if (uid is None) or (session.get(User, uid) is None): - if uid is None: - data["message"] = "The uid data field is required" - else: - data["message"] = f"Invalid user (uid={uid})" + valid, message = is_valid_user(session, uid) + if not valid: + data["message"] = message return data, 400 submission.uid = uid # Project project_id = request.form.get("project_id") - if project_id is None: - data["message"] = "The project_id data field is required" - return data, 400 - if not project_id.isdigit(): - data["message"] = f"Invalid project (project_id={project_id})" - return data, 400 - project = session.get(Project, int(project_id)) - if project is None: - data["message"] = f"Invalid project (project_id={project_id})" + valid, message = is_valid_project(session, project_id) + if not valid: + data["message"] = message return data, 400 submission.project_id = int(project_id) @@ -106,18 +100,11 @@ def post(self) -> dict[str, any]: submission.submission_time = datetime.now() # Submission path - # Filter out incorrect or empty files - files = list(filter(lambda file: - file and file.filename != "" and path.getsize(file.filename) > 0, - request.files.getlist("files") - )) - if not files: - data["message"] = "No files were uploaded" - return data, 400 - - # Check if all files are uploaded - if not all_files_uploaded(files, project.regex_expressions): - data["message"] = "Not all required files were uploaded" + files = filter_files(request.files.getlist("files")) + project = session.get(Project, submission.project_id) + if not files or not all_files_uploaded(files, project.regex_expressions): + data["message"] = "No files were uploaded" if not files else \ + "Not all required files were uploaded" return data, 400 # Zip the files and save the zip diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index d38ad3db..b577e218 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -1,5 +1,6 @@ """Utility functions for files""" +from os.path import getsize from re import match from typing import List, Union from io import BytesIO @@ -7,6 +8,20 @@ from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage +def filter_files(files: List[FileStorage]) -> List[FileStorage]: + """Filter out bad files + + Args: + files (List[FileStorage]): A list of files to filter on + + Returns: + List[FileStorage]: The filtered list + """ + return list(filter(lambda file: + file and file.filename != "" and getsize(file.filename) > 0, + files + )) + def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: """Check if all the required files are uploaded diff --git a/backend/project/utils/project.py b/backend/project/utils/project.py new file mode 100644 index 00000000..f056fa2b --- /dev/null +++ b/backend/project/utils/project.py @@ -0,0 +1,27 @@ +"""Utility functions for the project model""" + +from typing import Tuple +from sqlalchemy.orm import Session +from project.models.project import Project + +def is_valid_project(session: Session, project_id: any) -> Tuple[bool, str]: + """Check if a project_id is valid + + Args: + project_id (any): The project_id + + Returns: + bool: Is valid + """ + if project_id is None: + return False, "The project_id is missing" + + if isinstance(project_id, str) and project_id.isdigit(): + project_id = int(project_id) + elif not isinstance(project_id, int): + return False, f"Invalid project_id typing (project_id={project_id})" + + project = session.get(Project, project_id) + if project is None: + return False, f"Invalid project (project_id={project_id})" + return True, "Valid project" diff --git a/backend/project/utils/user.py b/backend/project/utils/user.py new file mode 100644 index 00000000..6b491066 --- /dev/null +++ b/backend/project/utils/user.py @@ -0,0 +1,26 @@ +"""Utility functions for the user model""" + +from typing import Tuple +from sqlalchemy.orm import Session +from project.models.user import User + +def is_valid_user(session: Session, uid: any) -> Tuple[bool, str]: + """Check if a uid is valid + + Args: + session (Session): A database session + uid (any): The uid + + Returns: + Tuple[bool, str]: Is valid + """ + if uid is None: + return False, "The uid is missing" + + if not isinstance(uid, str): + return False, f"Invalid uid typing (uid={uid})" + + user = session.get(User, uid) + if user is None: + return False, f"Invalid user (uid={uid})" + return True, "Valid user" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 13f1a853..0ff5b009 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,12 +7,18 @@ def db_session(): """Create a new database session for a test. After the test, all changes are rolled back and the session is closed.""" + db.metadata.create_all(engine) session = Session() - yield session - session.rollback() - session.close() - # Truncate all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() + + try: + yield session + finally: + # Rollback + session.rollback() + session.close() + + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 1b8f1a1e..de15708a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -6,7 +6,6 @@ from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker from project import create_app_with_db from project.db_in import url, db from project.models.course import Course @@ -136,39 +135,23 @@ def files(): with open(name02, "rb") as temp02: yield [(temp01, name01), (temp02, name02)] -engine = create_engine(url) -Session = sessionmaker(bind=engine) @pytest.fixture -def session(): +def session(db_session): """Create a database session for the tests""" - # Create all tables and get a session - db.metadata.create_all(engine) - session = Session() - - try: - # Populate the database - session.add_all(users()) - session.commit() - session.add_all(courses()) - session.commit() - session.add_all(course_relations(session)) - session.commit() - session.add_all(projects(session)) - session.commit() - session.add_all(submissions(session)) - session.commit() - - # Tests can now use a populated database - yield session - finally: - # Rollback - session.rollback() - session.close() + # Populate the database + db_session.add_all(users()) + db_session.commit() + db_session.add_all(courses()) + db_session.commit() + db_session.add_all(course_relations(db_session)) + db_session.commit() + db_session.add_all(projects(db_session)) + db_session.commit() + db_session.add_all(submissions(db_session)) + db_session.commit() - # Remove all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() + # Tests can now use a populated database + yield db_session @pytest.fixture def app(): @@ -240,19 +223,6 @@ def client(app): with app.app_context(): yield client -@pytest.fixture -def db_session(app): - """Create a new database session for a test. - After the test, all changes are rolled back and the session is closed.""" - app = create_app_with_db(url) - with app.app_context(): - for table in reversed(db.metadata.sorted_tables): - db.session.execute(table.delete()) - db.session.commit() - - yield db.session - db.session.close() - @pytest.fixture def courses_get_db(db_with_course): """Database equipped for the get tests""" diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index c99ac82d..7736bf90 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -77,7 +77,7 @@ def test_post_submissions_no_user(self, client: FlaskClient, session: Session, f }) data = response.json assert response.status_code == 400 - assert data["message"] == "The uid data field is required" + assert data["message"] == "The uid is missing" def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing user""" @@ -99,18 +99,18 @@ def test_post_submissions_no_project(self, client: FlaskClient, session: Session }) data = response.json assert response.status_code == 400 - assert data["message"] == "The project_id data field is required" + assert data["message"] == "The project_id is missing" def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session, files): """Test posting a submission for a non-existing project""" response = client.post("/submissions", data={ "uid": "student01", - "project_id": -1, + "project_id": 0, "files": files }) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=-1)" + assert data["message"] == "Invalid project (project_id=0)" def test_post_submissions_wrong_project_type( self, client: FlaskClient, session: Session, files @@ -123,7 +123,7 @@ def test_post_submissions_wrong_project_type( }) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=zero)" + assert data["message"] == "Invalid project_id typing (project_id=zero)" def test_post_submissions_no_files(self, client: FlaskClient, session: Session): """Test posting a submission when no files are uploaded""" From f257342e9ba13d6b1e353dbab676fbfc47eb5f7d Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 10 Mar 2024 16:32:31 +0100 Subject: [PATCH 142/377] docker-compose added --- backend/Dockerfile | 3 ++- backend/project/__main__.py | 2 +- docker-compose.yml | 19 +++++++++++++++++++ frontend/Dockerfile | 4 ++-- 4 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 docker-compose.yml diff --git a/backend/Dockerfile b/backend/Dockerfile index 8e4fa18e..b99144d6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,5 +2,6 @@ FROM python:3.9 RUN mkdir /app WORKDIR /app/ ADD ./project /app/ +COPY . . RUN pip3 install -r requirements.txt -CMD ["python3", "/app"] \ No newline at end of file +CMD ["python3", "-m","project"] \ No newline at end of file diff --git a/backend/project/__main__.py b/backend/project/__main__.py index a4bd122b..444d1410 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -7,4 +7,4 @@ if __name__ == "__main__": load_dotenv() app = create_app_with_db(url) - app.run(debug=True) + app.run(debug=True, host='0.0.0.0') diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..c55b1736 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "8080:80" + depends_on: + - backend + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "3000:3000" + diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 043eb166..44c3f196 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,6 @@ FROM node:18 as build WORKDIR /app -COPY package*.json . +COPY package*.json ./ RUN npm install COPY . . RUN npm run build @@ -9,4 +9,4 @@ FROM nginx:alpine WORKDIR /usr/share/nginx/html RUN rm -rf ./* COPY --from=build /app/dist /usr/share/nginx/html -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +CMD ["nginx", "-g", "daemon off;"] From 7f9d22e45265ed508f2f0a2ec2e9c94eb168288d Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 16:50:17 +0100 Subject: [PATCH 143/377] #15 - Will fix this later --- backend/tests/endpoints/courses_test.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/courses_test.py index 96b80459..5e9fde7d 100644 --- a/backend/tests/endpoints/courses_test.py +++ b/backend/tests/endpoints/courses_test.py @@ -188,20 +188,15 @@ def test_course_delete(self, courses_get_db, client): course = Course.query.filter_by(name="Sel2").first() assert course.teacher == "Bart" - response = client.delete( - "/courses/" + str(course.course_id) + "?uid=" + course.teacher - ) - assert response.status_code == 200 - course = courses_get_db.query(Course).filter_by(name="Sel2").first() - assert course is None - - def test_course_patch(self, db_with_course, client): + def test_course_patch(self, client, session): """ Test the patching of a course """ - body = {"name": "AD2"} - course = db_with_course.query(Course).filter_by(name="Sel2").first() - response = client.patch(f"/courses/{course.course_id}?uid=Bart", json=body) + course = session.query(Course).filter_by(name="AD3").first() + response = client.patch(f"/courses/{course.course_id}?uid=brinkmann", json={ + "name": "AD2" + }) + data = response.json assert response.status_code == 200 - assert course.name == "AD2" + assert data["data"]["name"] == "AD2" From 064f9634139485bdaacebbf756663958058ca0f8 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 10 Mar 2024 17:06:04 +0100 Subject: [PATCH 144/377] #15 - Linter has cyclic imports --- backend/project/endpoints/courses/course_details.py | 2 +- backend/project/endpoints/courses/course_student_relation.py | 2 +- backend/project/endpoints/courses/courses_utils.py | 2 +- backend/project/endpoints/users.py | 2 +- backend/project/models/course.py | 2 +- backend/project/models/course_relation.py | 2 +- backend/project/models/project.py | 2 +- backend/project/models/user.py | 2 +- backend/tests/endpoints/user_test.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index e4b4458f..41b4abd5 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -17,7 +17,7 @@ from project.models.course import Course from project.models.course_relation import CourseAdmin, CourseStudent -from project import db +from project.db_in import db from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model load_dotenv() diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index b86429f0..63b9213d 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -13,7 +13,7 @@ from flask import request from flask_restful import Resource -from project import db +from project.db_in import db from project.models.course_relation import CourseStudent from project.endpoints.courses.courses_utils import ( execute_query_abort_if_db_error, diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index cc5fdc1e..da496e0d 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -10,7 +10,7 @@ from flask import abort from sqlalchemy.exc import SQLAlchemyError -from project import db +from project.db_in import db from project.models.course_relation import CourseAdmin from project.models.user import User from project.models.course import Course diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 2febaffd..cfaf63db 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -6,7 +6,7 @@ from flask_restful import Resource, Api from sqlalchemy.exc import SQLAlchemyError -from project import db +from project.db_in import db from project.models.user import User as userModel users_bp = Blueprint("users", __name__) diff --git a/backend/project/models/course.py b/backend/project/models/course.py index 8d3f0651..09b37e5a 100644 --- a/backend/project/models/course.py +++ b/backend/project/models/course.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String -from project import db +from project.db_in import db @dataclass class Course(db.Model): diff --git a/backend/project/models/course_relation.py b/backend/project/models/course_relation.py index 9ee45c08..41b7d40b 100644 --- a/backend/project/models/course_relation.py +++ b/backend/project/models/course_relation.py @@ -1,6 +1,6 @@ """Models for relation between users and courses""" from sqlalchemy import Integer, Column, ForeignKey, PrimaryKeyConstraint, String -from project import db +from project.db_in import db class BaseCourseRelation(db.Model): """Base class for course relation models, diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 5171e1e6..06d76efc 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -2,7 +2,7 @@ import dataclasses from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text -from project import db +from project.db_in import db @dataclasses.dataclass class Project(db.Model): # pylint: disable=too-many-instance-attributes diff --git a/backend/project/models/user.py b/backend/project/models/user.py index d325a60c..7597462b 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -2,7 +2,7 @@ import dataclasses from sqlalchemy import Boolean, Column, String -from project import db +from project.db_in import db @dataclasses.dataclass class User(db.Model): diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 98ed1010..96b13e3c 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine from project.models.user import User -from project import db +from project.db_in import db from tests import db_url engine = create_engine(db_url) From 1fc9900339704f60c769a87750be2746afc0933e Mon Sep 17 00:00:00 2001 From: warre Date: Sun, 10 Mar 2024 17:57:19 +0100 Subject: [PATCH 145/377] change port --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index c55b1736..8f007744 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,5 @@ services: context: ./backend dockerfile: Dockerfile ports: - - "3000:3000" + - "5000:5000" From e7d3ba81663dcd4dddadab7e2fe4681edee829b7 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Mon, 11 Mar 2024 11:40:22 +0100 Subject: [PATCH 146/377] #15 - Small URL change --- backend/project/endpoints/submissions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index b7045501..07e3d4f9 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -159,6 +159,7 @@ def get(self, submission_id: int) -> dict[str, any]: with db.session() as session: submission = session.get(Submission, submission_id) if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 @@ -197,6 +198,7 @@ def patch(self, submission_id:int) -> dict[str, any]: # Get the submission submission = session.get(Submission, submission_id) if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 @@ -247,6 +249,7 @@ def delete(self, submission_id: int) -> dict[str, any]: with db.session() as session: submission = session.get(Submission, submission_id) if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") data["message"] = f"Submission (submission_id={submission_id}) not found" return data, 404 From 11377d16f83ce2e68d034de3530a9e281450032c Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 13:41:44 +0100 Subject: [PATCH 147/377] fixed extracting of zip and uploading in project upload directory --- .../project/endpoints/projects/projects.py | 32 +++++++++++--- backend/project/utils/query_agent.py | 44 ++++++++++++------- 2 files changed, 52 insertions(+), 24 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index af303cc9..03572a30 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -8,8 +8,10 @@ from flask import request from flask_restful import Resource +import zipfile + from project.models.projects import Project -from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.query_agent import query_selected_from_model, insert_into_model, create_model_instance from project.endpoints.projects.endpoint_parser import parse_project_params @@ -63,15 +65,31 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() - print("args") - print(arg) # save the file that is given with the request + + new_new_project = create_model_instance( + Project, + project_json, + urljoin(API_URL, "/projects"), + required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"] + ) + + print(new_new_project) + id = new_new_project.project_id + print(id) + project_upload_directory = f"{UPLOAD_FOLDER}{new_new_project.project_id}" + file_location = "."+os.path.join(project_upload_directory) + print(file_location) + # print(new_new_project.json) + if not os.path.exists(project_upload_directory): + os.makedirs(file_location) + if allowed_file(file.filename): - file.save("."+os.path.join(UPLOAD_FOLDER, file.filename)) + file.save(file_location+"/"+file.filename) + with zipfile.ZipFile(file_location+"/"+file.filename) as zip: + zip.extractall(file_location) else: print("no zip file given") - new_project = insert_into_model(Project, project_json, urljoin(API_URL, "/projects"), "project_id", required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"]) - - return new_project + return {}, 200 diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 24e857e2..5f258282 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -50,6 +50,28 @@ def delete_by_id_from_model( return {"error": "Something went wrong while deleting from the database.", "url": base_url}, 500 + +def create_model_instance(model: DeclarativeMeta, + data: Dict[str, Union[str, int]], + response_url_base: str, + required_fields: List[str] = None): + if required_fields is None: + required_fields = [] + # Check if all non-nullable fields are present in the data + missing_fields = [field for field in required_fields if field not in data] + + if missing_fields: + return {"error": f"Missing required fields: {', '.join(missing_fields)}", + "url": response_url_base}, 400 + + filtered_data = filter_model_fields(model, data) + new_instance: DeclarativeMeta = model(**filtered_data) + db.session.add(new_instance) + db.session.commit() + + return new_instance + + def insert_into_model(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, @@ -69,26 +91,14 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - if required_fields is None: - required_fields = [] - # Check if all non-nullable fields are present in the data - missing_fields = [field for field in required_fields if field not in data] - - if missing_fields: - return {"error": f"Missing required fields: {', '.join(missing_fields)}", - "url": response_url_base}, 400 - - filtered_data = filter_model_fields(model, data) - new_instance: DeclarativeMeta = model(**filtered_data) - db.session.add(new_instance) - db.session.commit() + new_instance = create_model_instance(model, data, response_url_base, required_fields) + return jsonify({ "data": new_instance, "message": "Object created succesfully.", - "url": urljoin(response_url_base, str(getattr(new_instance, url_id_field)))}), 201 - except SQLAlchemyError as e: - print("error") - print(e) + "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 + except SQLAlchemyError: + db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", "url": response_url_base}), 500 From bc6e4a9793e9176cb871e3a2d51353d02ac104a1 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 13:44:37 +0100 Subject: [PATCH 148/377] fix 400 when non zip is uploaded --- backend/project/endpoints/projects/projects.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 03572a30..e13f358e 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -90,6 +90,10 @@ def post(self): with zipfile.ZipFile(file_location+"/"+file.filename) as zip: zip.extractall(file_location) else: - print("no zip file given") + return {"message": "Please provide a .zip file for uploading the instructions"}, 400 - return {}, 200 + return { + "message": "Project created succesfully", + "data": new_new_project, + "url": f"{API_URL}/projects/{id}" + }, 200 From 62632be484243e8f04b14bbe8c7b2a962965b0d4 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 15:42:19 +0100 Subject: [PATCH 149/377] fixed tests --- .../project/endpoints/projects/projects.py | 28 ++++++++++--------- backend/project/utils/query_agent.py | 14 ++++++---- backend/tests.yaml | 1 + backend/tests/endpoints/conftest.py | 1 - backend/tests/endpoints/project_test.py | 15 ++++++---- 5 files changed, 35 insertions(+), 24 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index e13f358e..0da4861f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -19,6 +19,7 @@ UPLOAD_FOLDER = getenv('UPLOAD_URL') ALLOWED_EXTENSIONS = {'zip'} + def parse_immutabledict(request): output_json = {} for key, value in request.form.items(): @@ -31,9 +32,11 @@ def parse_immutabledict(request): output_json[key] = value return output_json + def allowed_file(filename: str): return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + class ProjectsEndpoint(Resource): """ @@ -65,6 +68,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() + filename = file.filename.split("/")[-1] # save the file that is given with the request @@ -74,26 +78,24 @@ def post(self): urljoin(API_URL, "/projects"), required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"] ) - - print(new_new_project) id = new_new_project.project_id - print(id) + project_upload_directory = f"{UPLOAD_FOLDER}{new_new_project.project_id}" - file_location = "."+os.path.join(project_upload_directory) - print(file_location) - # print(new_new_project.json) + + file_location = "." + os.path.join(project_upload_directory) + if not os.path.exists(project_upload_directory): - os.makedirs(file_location) + os.makedirs(file_location, exist_ok=True) - if allowed_file(file.filename): - file.save(file_location+"/"+file.filename) - with zipfile.ZipFile(file_location+"/"+file.filename) as zip: + file.save(file_location + "/" + filename) + try: + with zipfile.ZipFile(file_location + "/" + filename) as zip: zip.extractall(file_location) - else: + except zipfile.BadZipfile: return {"message": "Please provide a .zip file for uploading the instructions"}, 400 return { "message": "Project created succesfully", "data": new_new_project, "url": f"{API_URL}/projects/{id}" - }, 200 + }, 201 diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 5f258282..8eb7f4cc 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -92,11 +92,15 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance = create_model_instance(model, data, response_url_base, required_fields) - - return jsonify({ - "data": new_instance, - "message": "Object created succesfully.", - "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 + # if its a tuple the model instance couldn't be created so it already + # is the right format of error message and we just need to return + if isinstance(new_instance, tuple): + return new_instance + else: + return jsonify({ + "data": new_instance, + "message": "Object created succesfully.", + "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 except SQLAlchemyError: db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", diff --git a/backend/tests.yaml b/backend/tests.yaml index 7b799a2e..2807d904 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -29,6 +29,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here + UPLOAD_URL: /project/endpoints/uploads/ volumes: - .:/app command: ["pytest"] diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 0e964c22..143e463a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -33,7 +33,6 @@ def project(course): title="Project", descriptions="Test project", course_id=course.course_id, - assignment_file="testfile", deadline=date, visible_for_students=True, archieved=False, diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 1ebecce4..2ab36c26 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,5 +1,6 @@ """Tests for project endpoints.""" from project.models.projects import Project +import pytest def test_projects_home(client): """Test home project endpoint.""" @@ -23,9 +24,14 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ db_session.commit() project_json["course_id"] = course_ad.course_id + project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - response = client.post("/projects", json=project_json) + response = client.post( + "/projects", + data=project_json, + content_type='multipart/form-data' + ) assert response.status_code == 201 # check if the project with the id is present @@ -34,7 +40,6 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ assert response.status_code == 200 - def test_remove_project(db_session, client, course_ad, course_teacher_ad, project_json): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" @@ -45,10 +50,11 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec db_session.commit() project_json["course_id"] = course_ad.course_id + project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - response = client.post("/projects", json=project_json) - + response = client.post("/projects", data=project_json) + print(response) # check if the project with the id is present project_id = response.json["data"]["project_id"] @@ -59,7 +65,6 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec response = client.delete(f"/projects/{project_id}") assert response.status_code == 404 - def test_patch_project(db_session, client, course_ad, course_teacher_ad, project): """ Test functionality of the PUT method for projects From 6f323ec8f70e398a37e6fa411cc3edaed348e51e Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 15:44:03 +0100 Subject: [PATCH 150/377] added test zip file --- backend/testzip.zip | Bin 0 -> 175 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 backend/testzip.zip diff --git a/backend/testzip.zip b/backend/testzip.zip new file mode 100644 index 0000000000000000000000000000000000000000..f99b6a05484f7a0a5ffa496e382505de4987825a GIT binary patch literal 175 zcmWIWW@h1H00H-^>Y!K#PkYOlEEiTb3sVE5z;bdT5CiFgaB@mZZa5FHn zykKTv023fJX_+~xTmjyUOmfV)43hxa!N3T_TN*(ugwd=JqtT2F@MdKLsbd5}KOpT5 H;xGUJBf=uQ literal 0 HcmV?d00001 From b5f8ef9288c6bb1ba11fa9b2f6b47382aecb2b52 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 16:03:45 +0100 Subject: [PATCH 151/377] linter fixes --- backend/project/__main__.py | 2 - .../endpoints/projects/endpoint_parser.py | 21 ++++++++-- .../endpoints/projects/project_endpoint.py | 1 - .../project/endpoints/projects/projects.py | 41 +++++++++---------- backend/project/utils/query_agent.py | 16 +++++--- backend/tests/endpoints/project_test.py | 7 +++- 6 files changed, 53 insertions(+), 35 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 32547c6e..a4bd122b 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,7 +1,5 @@ """Main entry point for the application.""" -from sys import path -path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index d5ece633..7815bf5e 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -8,14 +8,29 @@ parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title', location="form") parser.add_argument('descriptions', type=str, help='Projects description', location="form") -parser.add_argument('assignment_file', type=werkzeug.datastructures.FileStorage, help='Projects assignment file', location="form") +parser.add_argument( + 'assignment_file', + type=werkzeug.datastructures.FileStorage, + help='Projects assignment file', + location="form" +) parser.add_argument("deadline", type=str, help='Projects deadline', location="form") parser.add_argument("course_id", type=str, help='Projects course_id', location="form") -parser.add_argument("visible_for_students", type=bool, help='Projects visibility for students', location="form") +parser.add_argument( + "visible_for_students", + type=bool, + help='Projects visibility for students', + location="form" +) parser.add_argument("archieved", type=bool, help='Projects', location="form") parser.add_argument("test_path", type=str, help='Projects test path', location="form") parser.add_argument("script_name", type=str, help='Projects test script path', location="form") -parser.add_argument("regex_expressions", type=str, help='Projects regex expressions', location="form") +parser.add_argument( + "regex_expressions", + type=str, + help='Projects regex expressions', + location="form" +) def parse_project_params(): diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index eef5b34d..09938878 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -4,7 +4,6 @@ """ from flask import Blueprint -from flask_restful import Api from project.endpoints.projects.projects import ProjectsEndpoint from project.endpoints.projects.project_detail import ProjectDetail diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 0da4861f..cbfddec0 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -5,13 +5,13 @@ from os import getenv from urllib.parse import urljoin +import zipfile + from flask import request from flask_restful import Resource -import zipfile - from project.models.projects import Project -from project.utils.query_agent import query_selected_from_model, insert_into_model, create_model_instance +from project.utils.query_agent import query_selected_from_model, create_model_instance from project.endpoints.projects.endpoint_parser import parse_project_params @@ -20,20 +20,13 @@ ALLOWED_EXTENSIONS = {'zip'} -def parse_immutabledict(request): - output_json = {} - for key, value in request.form.items(): - if value == "false": - print("false") - output_json[key] = False - if value == "true": - output_json[key] = True - else: - output_json[key] = value - return output_json + def allowed_file(filename: str): + """ + check if file extension is allowed for upload + """ return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @@ -72,15 +65,19 @@ def post(self): # save the file that is given with the request - new_new_project = create_model_instance( + new_project = create_model_instance( Project, project_json, urljoin(API_URL, "/projects"), - required_fields=["title", "descriptions", "course_id", "visible_for_students", "archieved"] + required_fields=[ + "title", + "descriptions", + "course_id", + "visible_for_students", + "archieved"] ) - id = new_new_project.project_id - project_upload_directory = f"{UPLOAD_FOLDER}{new_new_project.project_id}" + project_upload_directory = f"{UPLOAD_FOLDER}{new_project.project_id}" file_location = "." + os.path.join(project_upload_directory) @@ -89,13 +86,13 @@ def post(self): file.save(file_location + "/" + filename) try: - with zipfile.ZipFile(file_location + "/" + filename) as zip: - zip.extractall(file_location) + with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: + upload_zip.extractall(file_location) except zipfile.BadZipfile: return {"message": "Please provide a .zip file for uploading the instructions"}, 400 return { "message": "Project created succesfully", - "data": new_new_project, - "url": f"{API_URL}/projects/{id}" + "data": new_project, + "url": f"{API_URL}/projects/{new_project.project_id}" }, 201 diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 8eb7f4cc..c1729a6d 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -55,6 +55,9 @@ def create_model_instance(model: DeclarativeMeta, data: Dict[str, Union[str, int]], response_url_base: str, required_fields: List[str] = None): + """ + Create an instance of a model + """ if required_fields is None: required_fields = [] # Check if all non-nullable fields are present in the data @@ -96,11 +99,14 @@ def insert_into_model(model: DeclarativeMeta, # is the right format of error message and we just need to return if isinstance(new_instance, tuple): return new_instance - else: - return jsonify({ - "data": new_instance, - "message": "Object created succesfully.", - "url": urljoin(response_url_base + "/", str(getattr(new_instance, url_id_field)))}), 201 + + return (jsonify({ + "data": new_instance, + "message": "Object created succesfully.", + "url": + urljoin(response_url_base + "/", + str(getattr(new_instance, url_id_field)))}), + 201) except SQLAlchemyError: db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 2ab36c26..50d00d07 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,6 +1,5 @@ """Tests for project endpoints.""" from project.models.projects import Project -import pytest def test_projects_home(client): """Test home project endpoint.""" @@ -24,6 +23,8 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ db_session.commit() project_json["course_id"] = course_ad.course_id + # cant be done with 'with' because it autocloses then + # pylint: disable=R1732 project_json["assignment_file"] = open("testzip.zip", "rb") # post the project @@ -50,8 +51,10 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec db_session.commit() project_json["course_id"] = course_ad.course_id - project_json["assignment_file"] = open("testzip.zip", "rb") + # cant be done with 'with' because it autocloses then + # pylint: disable=R1732 + project_json["assignment_file"] = open("testzip.zip", "rb") # post the project response = client.post("/projects", data=project_json) print(response) From 72a9b7db31bc5397b2f8f79c4552a748f9bd0a3a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 16:08:11 +0100 Subject: [PATCH 152/377] removed some test files --- backend/project/endpoints/projects/endpoint_parser.py | 1 - backend/tests/endpoints/project_test.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 7815bf5e..2f5be9bb 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -39,7 +39,6 @@ def parse_project_params(): """ args = parser.parse_args() result_dict = {} - print(args) for key, value in args.items(): if value is not None: diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 50d00d07..54cedbf2 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -57,7 +57,7 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec project_json["assignment_file"] = open("testzip.zip", "rb") # post the project response = client.post("/projects", data=project_json) - print(response) + # check if the project with the id is present project_id = response.json["data"]["project_id"] From 12d0aa5f2d6dd9f84acb6fd26faed473357bec48 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:01:01 +0100 Subject: [PATCH 153/377] linter and test fixes --- backend/project/__init__.py | 1 - backend/project/endpoints/projects/projects.py | 5 ----- 2 files changed, 6 deletions(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index fa7891df..299412f1 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -7,7 +7,6 @@ from .endpoints.index.index import index_bp from .endpoints.projects.project_endpoint import project_bp from .endpoints.courses.courses_config import courses_bp -from .endpoints.courses.courses_config import courses_bp from .endpoints.users import users_bp diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index a1ba6cde..8d6ae75f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -4,17 +4,12 @@ import os from os import getenv from urllib.parse import urljoin - -from flask import request import zipfile from flask import request from flask_restful import Resource from project.models.project import Project -from project.utils.query_agent import query_selected_from_model, insert_into_model - -from project.models.projects import Project from project.utils.query_agent import query_selected_from_model, create_model_instance from project.endpoints.projects.endpoint_parser import parse_project_params From e1624b12bc31cc91595cdf2de67bffb272b1a272 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:50:49 +0100 Subject: [PATCH 154/377] import depedency fix --- backend/project/endpoints/projects/endpoint_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 2f5be9bb..3c64f9e5 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -3,14 +3,14 @@ """ from flask_restful import reqparse -import werkzeug +from werkzeug.datastructures import FileStorage parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title', location="form") parser.add_argument('descriptions', type=str, help='Projects description', location="form") parser.add_argument( 'assignment_file', - type=werkzeug.datastructures.FileStorage, + type=FileStorage, help='Projects assignment file', location="form" ) From 123992c4d3db058b812b788303ac6e1c87b5e38a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:53:57 +0100 Subject: [PATCH 155/377] fix import order --- backend/project/endpoints/projects/project_detail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index f8d4ebf1..85d7b99c 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -9,9 +9,9 @@ from flask import request from flask_restful import Resource +from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model -from project.models.project import Project From 2c3a71914fee62bc5c43f4de737ab7411c1009ae Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:54:31 +0100 Subject: [PATCH 156/377] removed import getenv --- backend/project/endpoints/projects/projects.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 8d6ae75f..84412ddf 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -2,7 +2,6 @@ Module that implements the /projects endpoint of the API """ import os -from os import getenv from urllib.parse import urljoin import zipfile From 33880d6c960bf78a0c89021eab07b3a529c31225 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 18:59:49 +0100 Subject: [PATCH 157/377] removed valid_project function --- backend/project/endpoints/projects/projects.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 84412ddf..00f8c9aa 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -13,21 +13,8 @@ from project.endpoints.projects.endpoint_parser import parse_project_params -API_URL = getenv('API_HOST') -UPLOAD_FOLDER = getenv('UPLOAD_URL') -ALLOWED_EXTENSIONS = {'zip'} - - - - - -def allowed_file(filename: str): - """ - check if file extension is allowed for upload - """ - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - +API_URL = os.getenv('API_HOST') +UPLOAD_FOLDER = os.getenv('UPLOAD_URL') class ProjectsEndpoint(Resource): """ From 10b0c1aa560aac5f3bf9a849fd643bf910c2fe2a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:14:50 +0100 Subject: [PATCH 158/377] fix fstring --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 00f8c9aa..261d5861 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -53,7 +53,7 @@ def post(self): new_project = create_model_instance( Project, project_json, - urljoin(API_URL, "/projects"), + urljoin(f"{API_URL}/", "/projects"), required_fields=[ "title", "descriptions", From 0edbd46c83872a656582405c68f9ab41e3db0a0c Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:19:30 +0100 Subject: [PATCH 159/377] fix: upload_directory --- backend/project/endpoints/projects/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 261d5861..ca1bf08a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -62,9 +62,9 @@ def post(self): "archieved"] ) - project_upload_directory = f"{UPLOAD_FOLDER}{new_project.project_id}" + project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") - file_location = "." + os.path.join(project_upload_directory) + file_location = os.path.join(project_upload_directory) if not os.path.exists(project_upload_directory): os.makedirs(file_location, exist_ok=True) From 3438972b1233a7ae14351ee6928528cc7d743312 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:21:56 +0100 Subject: [PATCH 160/377] removed exist_ok=True --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ca1bf08a..a2767c89 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -67,7 +67,7 @@ def post(self): file_location = os.path.join(project_upload_directory) if not os.path.exists(project_upload_directory): - os.makedirs(file_location, exist_ok=True) + os.makedirs(file_location) file.save(file_location + "/" + filename) try: From 8892d9873874f4bab4fc01f5bd47a33a46cc8816 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:28:16 +0100 Subject: [PATCH 161/377] use path.join --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index a2767c89..8602f39c 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -69,7 +69,7 @@ def post(self): if not os.path.exists(project_upload_directory): os.makedirs(file_location) - file.save(file_location + "/" + filename) + file.save(os.path.join(file_location, filename)) try: with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: upload_zip.extractall(file_location) From c5e3bc44b94e771ae5ae00f619b598bd08516f32 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:29:25 +0100 Subject: [PATCH 162/377] added url field --- backend/project/endpoints/projects/projects.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 8602f39c..5fde6a53 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -74,7 +74,11 @@ def post(self): with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: upload_zip.extractall(file_location) except zipfile.BadZipfile: - return {"message": "Please provide a .zip file for uploading the instructions"}, 400 + return ({ + "message": "Please provide a .zip file for uploading the instructions", + "url": f"{API_URL}/projects" + }, + 400) return { "message": "Project created succesfully", From 5c6a28deb9bd82bde9cf813babef027fd2bdc6ca Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:44:08 +0100 Subject: [PATCH 163/377] fixed not checking for tuple type anymore --- backend/project/endpoints/projects/projects.py | 2 +- backend/project/utils/query_agent.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 5fde6a53..d6091a2e 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -60,7 +60,7 @@ def post(self): "course_id", "visible_for_students", "archieved"] - ) + )[0] project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 41cb9e86..2398d9be 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -72,7 +72,7 @@ def create_model_instance(model: DeclarativeMeta, db.session.add(new_instance) db.session.commit() - return new_instance + return new_instance, 201 def insert_into_model(model: DeclarativeMeta, @@ -95,18 +95,20 @@ def insert_into_model(model: DeclarativeMeta, """ try: new_instance = create_model_instance(model, data, response_url_base, required_fields) + model_instance = new_instance[0] + status_code = new_instance[1] # if its a tuple the model instance couldn't be created so it already # is the right format of error message and we just need to return - if isinstance(new_instance, tuple): - return new_instance + if status_code == 400: + return model_instance, status_code return (jsonify({ - "data": new_instance, + "data": model_instance, "message": "Object created succesfully.", "url": urljoin(response_url_base + "/", - str(getattr(new_instance, url_id_field)))}), - 201) + str(getattr(model_instance, url_id_field)))}), + status_code) except SQLAlchemyError: db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", From 0be828f0aaf1286e732abc50d9113aa8653d3103 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:45:42 +0100 Subject: [PATCH 164/377] fixed env var for tests --- backend/tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests.yaml b/backend/tests.yaml index 2807d904..fd6d7a16 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -29,7 +29,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here - UPLOAD_URL: /project/endpoints/uploads/ + UPLOAD_URL: /data/assignments volumes: - .:/app command: ["pytest"] From adbb14baa4f0762fae7bfdb5f61bef192c3aabff Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:50:18 +0100 Subject: [PATCH 165/377] fixed env var for tests --- backend/tests/endpoints/project_test.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 369dc97b..8e2c49a9 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -54,9 +54,12 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec # cant be done with 'with' because it autocloses then # pylint: disable=R1732 - project_json["assignment_file"] = open("testzip.zip", "rb") + # project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - response = client.post("/projects", data=project_json) + # response = client.post("/projects", data=project_json) + with open("testzip.zip", "rb") as zip_file: + project_json["assignment_file"] = open("testzip.zip", "rb") + response = client.post("/projects", data=project_json) # check if the project with the id is present project_id = response.json["data"]["project_id"] From f0be9495970651d44cd5e42c479e1560427f69d9 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 19:53:41 +0100 Subject: [PATCH 166/377] fixed with statements --- backend/tests/endpoints/project_test.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 8e2c49a9..b833a03d 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -25,14 +25,15 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ project_json["course_id"] = course_ad.course_id # cant be done with 'with' because it autocloses then # pylint: disable=R1732 - project_json["assignment_file"] = open("testzip.zip", "rb") + with open("testzip.zip", "rb") as zip_file: + project_json["assignment_file"] = zip_file + # post the project + response = client.post( + "/projects", + data=project_json, + content_type='multipart/form-data' + ) - # post the project - response = client.post( - "/projects", - data=project_json, - content_type='multipart/form-data' - ) assert response.status_code == 201 # check if the project with the id is present @@ -52,13 +53,9 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec project_json["course_id"] = course_ad.course_id - # cant be done with 'with' because it autocloses then - # pylint: disable=R1732 - # project_json["assignment_file"] = open("testzip.zip", "rb") # post the project - # response = client.post("/projects", data=project_json) with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = open("testzip.zip", "rb") + project_json["assignment_file"] = zip_file response = client.post("/projects", data=project_json) # check if the project with the id is present From 220856fa81f1a5a6499bb4d8f19e645f7dec0886 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 20:02:34 +0100 Subject: [PATCH 167/377] using os.path.split instead of regular split --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index d6091a2e..2a0a2d9a 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -46,7 +46,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() - filename = file.filename.split("/")[-1] + filename = os.path.split(file.filename)[1] # save the file that is given with the request From 8c848d68f429f271c0299e1ab1cb9f675ce4fc5a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:20:01 +0100 Subject: [PATCH 168/377] added exist_ok --- backend/project/endpoints/projects/projects.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 2a0a2d9a..f7dec135 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -66,8 +66,7 @@ def post(self): file_location = os.path.join(project_upload_directory) - if not os.path.exists(project_upload_directory): - os.makedirs(file_location) + os.makedirs(file_location, exist_ok=True) file.save(os.path.join(file_location, filename)) try: From cb7eac1c8a8cc44938883980b717cec920686e13 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:25:43 +0100 Subject: [PATCH 169/377] i forgot :skull: fix lmao yeet --- backend/project/endpoints/projects/projects.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index f7dec135..624949ca 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -64,14 +64,12 @@ def post(self): project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") - file_location = os.path.join(project_upload_directory) + os.makedirs(project_upload_directory, exist_ok=True) - os.makedirs(file_location, exist_ok=True) - - file.save(os.path.join(file_location, filename)) + file.save(os.path.join(project_upload_directory, filename)) try: - with zipfile.ZipFile(file_location + "/" + filename) as upload_zip: - upload_zip.extractall(file_location) + with zipfile.ZipFile(project_upload_directory + "/" + filename) as upload_zip: + upload_zip.extractall(project_upload_directory) except zipfile.BadZipfile: return ({ "message": "Please provide a .zip file for uploading the instructions", From 6db6fada770ec71e37b30314a25da9a33046883f Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:27:14 +0100 Subject: [PATCH 170/377] i forgot :skull: fix lmao yeet --- backend/project/utils/query_agent.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 2398d9be..d530f8a6 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -94,9 +94,8 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - new_instance = create_model_instance(model, data, response_url_base, required_fields) - model_instance = new_instance[0] - status_code = new_instance[1] + model_instance, status_code = create_model_instance(model, data, response_url_base, required_fields) + # if its a tuple the model instance couldn't be created so it already # is the right format of error message and we just need to return if status_code == 400: From 058f53e5f925f4cfa3873223bcf4a0f787662e06 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:27:57 +0100 Subject: [PATCH 171/377] goofy augh fstring --- backend/project/utils/query_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index d530f8a6..ab6e8973 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -105,7 +105,7 @@ def insert_into_model(model: DeclarativeMeta, "data": model_instance, "message": "Object created succesfully.", "url": - urljoin(response_url_base + "/", + urljoin(f"{response_url_base}/", str(getattr(model_instance, url_id_field)))}), status_code) except SQLAlchemyError: From 2314ff6652a66fd65777637baa826ee42c096c4f Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:29:22 +0100 Subject: [PATCH 172/377] another small fix --- backend/project/endpoints/projects/projects.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 624949ca..6369b4fc 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -50,7 +50,7 @@ def post(self): # save the file that is given with the request - new_project = create_model_instance( + new_project, _ = create_model_instance( Project, project_json, urljoin(f"{API_URL}/", "/projects"), @@ -60,7 +60,7 @@ def post(self): "course_id", "visible_for_students", "archieved"] - )[0] + ) project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") From 251ff2905d7d5b81a1dea60a2f6c3b6d3df79971 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:31:37 +0100 Subject: [PATCH 173/377] fixed the 'not fixed eh' problem --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 6369b4fc..39815429 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -68,7 +68,7 @@ def post(self): file.save(os.path.join(project_upload_directory, filename)) try: - with zipfile.ZipFile(project_upload_directory + "/" + filename) as upload_zip: + with zipfile.ZipFile(os.path.join(project_upload_directory, filename)) as upload_zip: upload_zip.extractall(project_upload_directory) except zipfile.BadZipfile: return ({ From f01fed999aa07437503bb54755891245f11d3f63 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:34:35 +0100 Subject: [PATCH 174/377] linting --- backend/project/utils/query_agent.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index ab6e8973..bcdf1ea0 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -94,7 +94,11 @@ def insert_into_model(model: DeclarativeMeta, a message indicating that something went wrong while inserting into the database. """ try: - model_instance, status_code = create_model_instance(model, data, response_url_base, required_fields) + model_instance, status_code = create_model_instance( + model, + data, + response_url_base, + required_fields) # if its a tuple the model instance couldn't be created so it already # is the right format of error message and we just need to return From d53eef1061720364d33e7a0c381a41a7b43a7408 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Mon, 11 Mar 2024 21:39:34 +0100 Subject: [PATCH 175/377] fix handling fail --- backend/project/endpoints/projects/projects.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 39815429..bfa65a0e 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -50,7 +50,7 @@ def post(self): # save the file that is given with the request - new_project, _ = create_model_instance( + new_project, status_code = create_model_instance( Project, project_json, urljoin(f"{API_URL}/", "/projects"), @@ -62,6 +62,9 @@ def post(self): "archieved"] ) + if status_code == 400: + return new_project, status_code + project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") os.makedirs(project_upload_directory, exist_ok=True) From dad70154761b641e52f1f0fc52cdc5b7b881eaf4 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Tue, 12 Mar 2024 12:42:57 +0100 Subject: [PATCH 176/377] added try block --- .../project/endpoints/projects/projects.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index bfa65a0e..b86bd021 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -49,18 +49,22 @@ def post(self): filename = os.path.split(file.filename)[1] # save the file that is given with the request - - new_project, status_code = create_model_instance( - Project, - project_json, - urljoin(f"{API_URL}/", "/projects"), - required_fields=[ - "title", - "descriptions", - "course_id", - "visible_for_students", - "archieved"] - ) + try: + new_project, status_code = create_model_instance( + Project, + project_json, + urljoin(f"{API_URL}/", "/projects"), + required_fields=[ + "title", + "descriptions", + "course_id", + "visible_for_students", + "archieved"] + ) + except SQLAlchemyError: + db.session.rollback() + return jsonify({"error": "Something went wrong while inserting into the database.", + "url": f"{API_URL}/projects"}), 500 if status_code == 400: return new_project, status_code From f4fe9fa83979bfcb0c60c14bd38fa4cd18de63a5 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Tue, 12 Mar 2024 13:09:57 +0100 Subject: [PATCH 177/377] linter --- backend/project/endpoints/projects/projects.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index b86bd021..ed26f568 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -4,10 +4,12 @@ import os from urllib.parse import urljoin import zipfile +from sqlalchemy.exc import SQLAlchemyError -from flask import request +from flask import request, jsonify from flask_restful import Resource + from project.models.project import Project from project.utils.query_agent import query_selected_from_model, create_model_instance @@ -62,7 +64,6 @@ def post(self): "archieved"] ) except SQLAlchemyError: - db.session.rollback() return jsonify({"error": "Something went wrong while inserting into the database.", "url": f"{API_URL}/projects"}), 500 From 998e69e60b88766c469f0dbf3a477c6fa410e5df Mon Sep 17 00:00:00 2001 From: warre Date: Tue, 12 Mar 2024 15:17:21 +0100 Subject: [PATCH 178/377] nginx.conf added --- nginx.conf | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 nginx.conf diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 00000000..f47deca4 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,103 @@ +user www-data; +worker_processes auto; +pid /run/nginx.pid; +include /etc/nginx/modules-enabled/*.conf; + +events { + worker_connections 768; + # multi_accept on; +} + +http { + server { + server_name sel2-3.ugent.be localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + proxy_pass http://localhost:8080; + } + + + location /api/{ + proxy_pass http://localhost:5000/; + } + + listen [::]:443 ssl ipv6only=on; # managed by Certbot + listen 443 ssl; # managed by Certbot + ssl_certificate /etc/letsencrypt/live/sel2-3.ugent.be/fullchain.pem; # managed by Certbot + ssl_certificate_key /etc/letsencrypt/live/sel2-3.ugent.be/privkey.pem; # managed by Certbot + include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot + +} + + + ## + # Basic Settings + ## + + sendfile on; + tcp_nopush on; + types_hash_max_size 2048; + # server_tokens off; + + # server_names_hash_bucket_size 64; + # server_name_in_redirect off; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + ## + # SSL Settings + ## + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE + ssl_prefer_server_ciphers on; + + ## + # Logging Settings + ## + + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + ## + # Gzip Settings + ## + + gzip on; + + # gzip_vary on; + # gzip_proxied any; + # gzip_comp_level 6; + # gzip_buffers 16 8k; + # gzip_http_version 1.1; + # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + ## + # Virtual Host Configs + ## + + include /etc/nginx/conf.d/*.conf; + include /etc/nginx/sites-enabled/*; + + + + server { + if ($host = sel2-3.ugent.be) { + return 301 https://$host$request_uri; + } # managed by Certbot + + + listen 80; + listen [::]:80; + server_name sel2-3.ugent.be localhost; + return 404; # managed by Certbot + + +}} + + + From 5dc72f05b68907a0c3da4db94021ff564aa1d9dd Mon Sep 17 00:00:00 2001 From: warre Date: Tue, 12 Mar 2024 17:14:19 +0100 Subject: [PATCH 179/377] rm conf --- nginx.conf | 103 ----------------------------------------------------- 1 file changed, 103 deletions(-) delete mode 100644 nginx.conf diff --git a/nginx.conf b/nginx.conf deleted file mode 100644 index f47deca4..00000000 --- a/nginx.conf +++ /dev/null @@ -1,103 +0,0 @@ -user www-data; -worker_processes auto; -pid /run/nginx.pid; -include /etc/nginx/modules-enabled/*.conf; - -events { - worker_connections 768; - # multi_accept on; -} - -http { - server { - server_name sel2-3.ugent.be localhost; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - proxy_pass http://localhost:8080; - } - - - location /api/{ - proxy_pass http://localhost:5000/; - } - - listen [::]:443 ssl ipv6only=on; # managed by Certbot - listen 443 ssl; # managed by Certbot - ssl_certificate /etc/letsencrypt/live/sel2-3.ugent.be/fullchain.pem; # managed by Certbot - ssl_certificate_key /etc/letsencrypt/live/sel2-3.ugent.be/privkey.pem; # managed by Certbot - include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot - -} - - - ## - # Basic Settings - ## - - sendfile on; - tcp_nopush on; - types_hash_max_size 2048; - # server_tokens off; - - # server_names_hash_bucket_size 64; - # server_name_in_redirect off; - - include /etc/nginx/mime.types; - default_type application/octet-stream; - - ## - # SSL Settings - ## - - ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE - ssl_prefer_server_ciphers on; - - ## - # Logging Settings - ## - - access_log /var/log/nginx/access.log; - error_log /var/log/nginx/error.log; - - ## - # Gzip Settings - ## - - gzip on; - - # gzip_vary on; - # gzip_proxied any; - # gzip_comp_level 6; - # gzip_buffers 16 8k; - # gzip_http_version 1.1; - # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; - - ## - # Virtual Host Configs - ## - - include /etc/nginx/conf.d/*.conf; - include /etc/nginx/sites-enabled/*; - - - - server { - if ($host = sel2-3.ugent.be) { - return 301 https://$host$request_uri; - } # managed by Certbot - - - listen 80; - listen [::]:80; - server_name sel2-3.ugent.be localhost; - return 404; # managed by Certbot - - -}} - - - From c5c8c90aa61a7d8f760a766a5b3e1702fcf8871c Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:29:57 +0100 Subject: [PATCH 180/377] fixed archieved to archived typo (#84) * fixed archieved to archived typo * fixed typo descriptions to description * last change * tests passed! --- backend/db_construct.sql | 4 ++-- backend/project/endpoints/projects/endpoint_parser.py | 4 ++-- backend/project/endpoints/projects/projects.py | 6 +++--- backend/project/models/project.py | 4 ++-- backend/tests/endpoints/conftest.py | 8 ++++---- backend/tests/endpoints/project_test.py | 9 ++++++--- backend/tests/models/conftest.py | 4 ++-- 7 files changed, 21 insertions(+), 18 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index d0884c13..8c42e382 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -30,12 +30,12 @@ CREATE TABLE course_students ( CREATE TABLE projects ( project_id INT GENERATED ALWAYS AS IDENTITY, title VARCHAR(50) NOT NULL, - descriptions TEXT NOT NULL, + description TEXT NOT NULL, assignment_file VARCHAR(50), deadline TIMESTAMP WITH TIME ZONE, course_id INT NOT NULL, visible_for_students BOOLEAN NOT NULL, - archieved BOOLEAN NOT NULL, + archived BOOLEAN NOT NULL, test_path VARCHAR(50), script_name VARCHAR(50), regex_expressions VARCHAR(50)[], diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 3c64f9e5..d9737826 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -7,7 +7,7 @@ parser = reqparse.RequestParser() parser.add_argument('title', type=str, help='Projects title', location="form") -parser.add_argument('descriptions', type=str, help='Projects description', location="form") +parser.add_argument('description', type=str, help='Projects description', location="form") parser.add_argument( 'assignment_file', type=FileStorage, @@ -22,7 +22,7 @@ help='Projects visibility for students', location="form" ) -parser.add_argument("archieved", type=bool, help='Projects', location="form") +parser.add_argument("archived", type=bool, help='Projects', location="form") parser.add_argument("test_path", type=str, help='Projects test path', location="form") parser.add_argument("script_name", type=str, help='Projects test script path', location="form") parser.add_argument( diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ed26f568..c394a85d 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -35,7 +35,7 @@ def get(self): return query_selected_from_model( Project, response_url, - select_values=["project_id", "title", "descriptions"], + select_values=["project_id", "title", "description"], url_mapper={"project_id": response_url}, filters=request.args ) @@ -58,10 +58,10 @@ def post(self): urljoin(f"{API_URL}/", "/projects"), required_fields=[ "title", - "descriptions", + "description", "course_id", "visible_for_students", - "archieved"] + "archived"] ) except SQLAlchemyError: return jsonify({"error": "Something went wrong while inserting into the database.", diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 5171e1e6..fad0b1a8 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -22,12 +22,12 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes __tablename__ = "projects" project_id: int = Column(Integer, primary_key=True) title: str = Column(String(50), nullable=False, unique=False) - descriptions: str = Column(Text, nullable=False) + description: str = Column(Text, nullable=False) assignment_file: str = Column(String(50)) deadline: str = Column(DateTime(timezone=True)) course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) visible_for_students: bool = Column(Boolean, nullable=False) - archieved: bool = Column(Boolean, nullable=False) + archived: bool = Column(Boolean, nullable=False) test_path: str = Column(String(50)) script_name: str = Column(String(50)) regex_expressions: list[str] = Column(ARRAY(String(50))) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 1861ec85..bb62ae0e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -31,11 +31,11 @@ def project(course): date = datetime(2024, 2, 25, 12, 0, 0) project = Project( title="Project", - descriptions="Test project", + description="Test project", course_id=course.course_id, deadline=date, visible_for_students=True, - archieved=False, + archived=False, test_path="testpad", script_name="testscript", regex_expressions='r' @@ -48,12 +48,12 @@ def project_json(project: Project): """A function that return the json data of a project including the PK neede for testing""" data = { "title": project.title, - "descriptions": project.descriptions, + "description": project.description, "assignment_file": project.assignment_file, "deadline": project.deadline, "course_id": project.course_id, "visible_for_students": project.visible_for_students, - "archieved": project.archieved, + "archived": project.archived, "test_path": project.test_path, "script_name": project.script_name, "regex_expressions": project.regex_expressions diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index b833a03d..906ca596 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -54,11 +54,14 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec project_json["course_id"] = course_ad.course_id # post the project + print(project_json) with open("testzip.zip", "rb") as zip_file: project_json["assignment_file"] = zip_file response = client.post("/projects", data=project_json) # check if the project with the id is present + print("joink") + print(response) project_id = response.json["data"]["project_id"] response = client.delete(f"/projects/{project_id}") @@ -87,14 +90,14 @@ def test_patch_project(db_session, client, course_ad, course_teacher_ad, project project_id = project.project_id new_title = "patched title" - new_archieved = not project.archieved + new_archived = not project.archived response = client.patch(f"/projects/{project_id}", json={ - "title": new_title, "archieved": new_archieved + "title": new_title, "archived": new_archived }) db_session.commit() updated_project = db_session.get(Project, {"project_id": project.project_id}) assert response.status_code == 200 assert updated_project.title == new_title - assert updated_project.archieved == new_archieved + assert updated_project.archived == new_archived diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py index 1a272500..dbc0dc19 100644 --- a/backend/tests/models/conftest.py +++ b/backend/tests/models/conftest.py @@ -76,9 +76,9 @@ def valid_project(): deadline = datetime(2024, 2, 25, 12, 0, 0) # February 25, 2024, 12:00 PM project = Project( title="Project", - descriptions="Test project", + description="Test project", deadline=deadline, visible_for_students=True, - archieved=False, + archived=False, ) return project From 6333ae43b7d274118d7d73c61013842b88f8a042 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 12 Mar 2024 22:07:28 +0100 Subject: [PATCH 181/377] #15 - Spelling --- backend/project/endpoints/submissions.py | 2 +- backend/tests/endpoints/conftest.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 07e3d4f9..62d289ae 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -233,7 +233,7 @@ def patch(self, submission_id:int) -> dict[str, any]: return data, 500 def delete(self, submission_id: int) -> dict[str, any]: - """Delete a submission given an submission ID + """Delete a submission given a submission ID Args: submission_id (int): Submission ID diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index de15708a..77bf556e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -155,7 +155,7 @@ def session(db_session): @pytest.fixture def app(): - """A fixture that creates and configure a new app instance for each test. + """A fixture that creates and configures a new app instance for each test. Returns: Flask -- A Flask application instance """ @@ -196,7 +196,7 @@ def project(course): @pytest.fixture def project_json(project: Project): - """A function that return the json data of a project including the PK neede for testing""" + """A function that return the json data of a project including the PK needed for testing""" data = { "title": project.title, "descriptions": project.descriptions, From 3972bb110c0dae3fa4fea21a21ed9b645c2284f6 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 12 Mar 2024 23:10:25 +0100 Subject: [PATCH 182/377] #15 - Fixing tests the merge broke --- backend/project/endpoints/index/OpenAPI_Object.json | 6 +++--- backend/project/models/project.py | 2 +- backend/tests/endpoints/conftest.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 3b70d27d..829f7c38 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -58,7 +58,7 @@ "project_id": { "type": "int" }, - "descriptions": { + "description": { "type": "string" }, "title": { @@ -102,7 +102,7 @@ "schema": { "type": "object", "properties": { - "archieved": { + "archived": { "type": "bool" }, "assignment_file": { @@ -114,7 +114,7 @@ "deadline": { "type": "date" }, - "descriptions": { + "description": { "type": "array", "items": { "description": "string" diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 0bd200f1..0ed6c495 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -12,7 +12,7 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes an optional deadline, the course id of the course to which the project belongs, visible for students variable so a teacher can decide if the students can see it yet, - archieved var so we can implement the archiving functionality, + archived var so we can implement the archiving functionality, a test path,script name and regex expressions for automated testing Pylint disable too many instance attributes because we can't reduce the amount diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index b3f9cdaa..eed9da77 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -51,24 +51,24 @@ def projects(session): return [ Project( title="B+ Trees", - descriptions="Implement B+ trees", + description="Implement B+ trees", assignment_file="assignement.pdf", deadline=datetime(2024,3,15,13,0,0), course_id=course_id_ad3, visible_for_students=True, - archieved=False, + archived=False, test_path="/tests", script_name="script.sh", regex_expressions=["solution"] ), Project( title="Predicaten", - descriptions="Predicaten project", + description="Predicaten project", assignment_file="assignment.pdf", deadline=datetime(2023,3,15,13,0,0), course_id=course_id_raf, visible_for_students=False, - archieved=True, + archived=True, test_path="/tests", script_name="script.sh", regex_expressions=[".*"] From 7dc392306fd5d09ca10a5143c5201fc2c836d0ab Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Wed, 13 Mar 2024 12:58:14 +0100 Subject: [PATCH 183/377] Feature/share link (#72) * create subfolder for course tests * added table for share codes * loading env variables * added default value to function parameters * added endpoint for join_codes * added tests for join_codes * fixed: linting * fixed typo: * fixed import conftest * fixed merge with singular model naming * unused file --- backend/db_construct.sql | 22 ++++++++ backend/project/__init__.py | 2 + backend/project/db_in.py | 2 + .../endpoints/courses/courses_utils.py | 6 +- .../courses/join_codes/course_join_code.py | 48 ++++++++++++++++ .../courses/join_codes/course_join_codes.py | 55 +++++++++++++++++++ .../courses/join_codes/join_codes_config.py | 24 ++++++++ .../courses/join_codes/join_codes_utils.py | 14 +++++ backend/project/models/course_share_code.py | 22 ++++++++ backend/tests/endpoints/conftest.py | 15 ++++- .../endpoints/{ => course}/courses_test.py | 0 .../tests/endpoints/course/share_link_test.py | 53 ++++++++++++++++++ 12 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 backend/project/endpoints/courses/join_codes/course_join_code.py create mode 100644 backend/project/endpoints/courses/join_codes/course_join_codes.py create mode 100644 backend/project/endpoints/courses/join_codes/join_codes_config.py create mode 100644 backend/project/endpoints/courses/join_codes/join_codes_utils.py create mode 100644 backend/project/models/course_share_code.py rename backend/tests/endpoints/{ => course}/courses_test.py (100%) create mode 100644 backend/tests/endpoints/course/share_link_test.py diff --git a/backend/db_construct.sql b/backend/db_construct.sql index 8c42e382..e18f7782 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -14,6 +14,14 @@ CREATE TABLE courses ( PRIMARY KEY(course_id) ); +CREATE TABLE course_join_codes ( + join_code UUID DEFAULT gen_random_uuid() NOT NULL, + course_id INT NOT NULL, + expiry_time DATE, + for_admins BOOLEAN NOT NULL, + CONSTRAINT fk_course_join_link FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE, + PRIMARY KEY(join_code) +); CREATE TABLE course_admins ( course_id INT NOT NULL REFERENCES courses(course_id) ON DELETE CASCADE, @@ -55,3 +63,17 @@ CREATE TABLE submissions ( CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) ); + +CREATE OR REPLACE FUNCTION remove_expired_codes() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM course_join_codes + WHERE expiry_time < CURRENT_DATE; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER remove_expired_codes_trigger +AFTER INSERT OR UPDATE ON course_join_codes +FOR EACH ROW EXECUTE FUNCTION remove_expired_codes(); diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 67c05bcf..664ff947 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -9,6 +9,7 @@ from .endpoints.courses.courses_config import courses_bp from .endpoints.projects.project_endpoint import project_bp from .endpoints.submissions import submissions_bp +from .endpoints.courses.join_codes.join_codes_config import join_codes_bp def create_app(): """ @@ -23,6 +24,7 @@ def create_app(): app.register_blueprint(courses_bp) app.register_blueprint(project_bp) app.register_blueprint(submissions_bp) + app.register_blueprint(join_codes_bp) return app diff --git a/backend/project/db_in.py b/backend/project/db_in.py index 57a572fa..76534360 100644 --- a/backend/project/db_in.py +++ b/backend/project/db_in.py @@ -1,10 +1,12 @@ """db initialization""" import os +from dotenv import load_dotenv from flask_sqlalchemy import SQLAlchemy from sqlalchemy import URL db = SQLAlchemy() +load_dotenv() DATABSE_NAME = os.getenv("POSTGRES_DB") DATABASE_USER = os.getenv("POSTGRES_USER") diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index da496e0d..0489e775 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -213,7 +213,7 @@ def json_message(message): return {"message": message} -def get_course_abort_if_not_found(course_id): +def get_course_abort_if_not_found(course_id, url=f"{API_URL}/courses"): """ Get a course by its ID. @@ -224,11 +224,11 @@ def get_course_abort_if_not_found(course_id): Course: The course with the given ID. """ query = Course.query.filter_by(course_id=course_id) - course = execute_query_abort_if_db_error(query, f"{API_URL}/courses") + course = execute_query_abort_if_db_error(query, url) if not course: response = json_message("Course not found") - response["url"] = f"{API_URL}/courses" + response["url"] = url abort(404, description=response) return course diff --git a/backend/project/endpoints/courses/join_codes/course_join_code.py b/backend/project/endpoints/courses/join_codes/course_join_code.py new file mode 100644 index 00000000..df952877 --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/course_join_code.py @@ -0,0 +1,48 @@ +""" +This file will contain the api endpoints for the /courses//join_codes url +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask_restful import Resource +from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model +from project.models.course_share_code import CourseShareCode +from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") + +class CourseJoinCode(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/join_codes url, only an admin of a course can do this + """ + + @check_course_exists + def get(self, course_id, join_code): + """ + This function will return all the join codes of a course + """ + + return query_by_id_from_model( + CourseShareCode, + "join_code", + join_code, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes") + ) + + @check_course_exists + def delete(self, course_id, join_code): + """ + Api endpoint for adding new join codes to a course, can only be done by the teacher + """ + + return delete_by_id_from_model( + CourseShareCode, + "join_code", + join_code, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes") + ) diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py new file mode 100644 index 00000000..7ab142b6 --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -0,0 +1,55 @@ +""" +This file will contain the api endpoints for the /courses//join_codes url +""" + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv + +from flask_restful import Resource +from flask import request +from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.models.course_share_code import CourseShareCode +from project.endpoints.courses.courses_utils import get_course_abort_if_not_found + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") + +class CourseJoinCodes(Resource): + """ + This class will handle post and delete queries to + the /courses/course_id/join_codes url, only an admin of a course can do this + """ + + def get(self, course_id): + """ + This function will return all the join codes of a course + """ + + get_course_abort_if_not_found(course_id) + + return query_selected_from_model( + CourseShareCode, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"), + select_values=["join_code", "expiry_time"], + filters={"course_id": course_id} + ) + + def post(self, course_id): + """ + Api endpoint for adding new join codes to a course, can only be done by the teacher + """ + + get_course_abort_if_not_found(course_id) + + data = request.get_json() + data["course_id"] = course_id + + return insert_into_model( + CourseShareCode, + data, + urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"), + "join_code", + required_fields=["for_admins"] + ) diff --git a/backend/project/endpoints/courses/join_codes/join_codes_config.py b/backend/project/endpoints/courses/join_codes/join_codes_config.py new file mode 100644 index 00000000..a2ae0bce --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/join_codes_config.py @@ -0,0 +1,24 @@ +""" +This file is used to configure the join codes endpoints. +It is used to define the routes for the join codes blueprint and the +corresponding api endpoints. + +The join codes blueprint is used to define the routes for the join codes api +endpoints and the join codes api is used to define the routes for the join codes +api endpoints. +""" + +from flask import Blueprint +from flask_restful import Api + +from project.endpoints.courses.join_codes.course_join_codes import CourseJoinCodes +from project.endpoints.courses.join_codes.course_join_code import CourseJoinCode + +join_codes_bp = Blueprint("join_codes", __name__) +join_codes_api = Api(join_codes_bp) + +join_codes_bp.add_url_rule("/courses//join_codes", + view_func=CourseJoinCodes.as_view('course_join_codes')) + +join_codes_bp.add_url_rule("/courses//join_codes/", + view_func=CourseJoinCode.as_view('course_join_code')) diff --git a/backend/project/endpoints/courses/join_codes/join_codes_utils.py b/backend/project/endpoints/courses/join_codes/join_codes_utils.py new file mode 100644 index 00000000..5078fce2 --- /dev/null +++ b/backend/project/endpoints/courses/join_codes/join_codes_utils.py @@ -0,0 +1,14 @@ +""" +This module contains functions that are used by the join codes resources. +""" + +from project.endpoints.courses.courses_utils import get_course_abort_if_not_found + +def check_course_exists(func): + """ + Middleware to check if the course exists before handling the request + """ + def wrapper(self, course_id, join_code, *args, **kwargs): + get_course_abort_if_not_found(course_id) + return func(self, course_id, join_code, *args, **kwargs) + return wrapper diff --git a/backend/project/models/course_share_code.py b/backend/project/models/course_share_code.py new file mode 100644 index 00000000..67fbad92 --- /dev/null +++ b/backend/project/models/course_share_code.py @@ -0,0 +1,22 @@ +""" +Course Share Code Model +""" + + +from dataclasses import dataclass +import uuid +from sqlalchemy import Integer, Column, ForeignKey, Date, Boolean +from sqlalchemy.dialects.postgresql import UUID +from project import db + +@dataclass +class CourseShareCode(db.Model): + """ + This class will contain the model for the course share codes + """ + __tablename__ = "course_join_codes" + + join_code: int = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) + expiry_time: str = Column(Date, nullable=True) + for_admins: bool = Column(Boolean, nullable=False) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index eed9da77..7c71842e 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -6,12 +6,14 @@ from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine -from project import create_app_with_db -from project.db_in import url, db + from project.models.course import Course from project.models.user import User from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin +from project.models.course_share_code import CourseShareCode +from project import create_app_with_db +from project.db_in import url, db from project.models.submission import Submission def users(): @@ -299,3 +301,12 @@ def course(course_teacher): """A course for testing, with the course teacher as the teacher.""" sel2 = Course(name="Sel2", teacher=course_teacher.uid) return sel2 + +@pytest.fixture +def share_code_admin(db_with_course): + """A course with share codes for testing.""" + course = db_with_course.query(Course).first() + share_code = CourseShareCode(course_id=course.course_id, for_admins=True) + db_with_course.add(share_code) + db_with_course.commit() + return share_code diff --git a/backend/tests/endpoints/courses_test.py b/backend/tests/endpoints/course/courses_test.py similarity index 100% rename from backend/tests/endpoints/courses_test.py rename to backend/tests/endpoints/course/courses_test.py diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py new file mode 100644 index 00000000..85f3346e --- /dev/null +++ b/backend/tests/endpoints/course/share_link_test.py @@ -0,0 +1,53 @@ +""" +This file contains the tests for the share link endpoints of the course resource. +""" + +from project.models.course import Course + +class TestCourseShareLinks: + """ + Class that will respond to the /courses/course_id/students link + teachers should be able to assign and remove students from courses, + and everyone should be able to list all students assigned to a course + """ + + def test_get_share_links(self, db_with_course, client): + """Test whether the share links are accessible""" + example_course = db_with_course.query(Course).first() + response = client.get(f"courses/{example_course.course_id}/join_codes") + assert response.status_code == 200 + + def test_post_share_links(self, db_with_course, client): + """Test whether the share links are accessible to post to""" + example_course = db_with_course.query(Course).first() + response = client.post( + f"courses/{example_course.course_id}/join_codes", + json={"for_admins": True}) + assert response.status_code == 201 + + def test_delete_share_links(self, share_code_admin, client): + """Test whether the share links are accessible to delete""" + response = client.delete( + f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}") + assert response.status_code == 200 + + def test_get_share_links_404(self, client): + """Test whether the share links are accessible""" + response = client.get("courses/0/join_codes") + assert response.status_code == 404 + + def test_post_share_links_404(self, client): + """Test whether the share links are accessible to post to""" + response = client.post("courses/0/join_codes", json={"for_admins": True}) + assert response.status_code == 404 + + def test_delete_share_links_404(self, client): + """Test whether the share links are accessible to delete""" + response = client.delete("courses/0/join_codes/0") + assert response.status_code == 404 + + def test_for_admins_required(self, db_with_course, client): + """Test whether the for_admins field is required""" + example_course = db_with_course.query(Course).first() + response = client.post(f"courses/{example_course.course_id}/join_codes", json={}) + assert response.status_code == 400 From 88aef976fb929928c2fdc63764be700ea807615e Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:01:27 +0100 Subject: [PATCH 184/377] added functionlity for downloading files --- backend/project/__main__.py | 2 ++ .../projects/project_assignment_file.py | 32 +++++++++++++++++++ .../endpoints/projects/project_detail.py | 2 +- .../endpoints/projects/project_endpoint.py | 6 ++++ .../project/endpoints/projects/projects.py | 2 +- 5 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 backend/project/endpoints/projects/project_assignment_file.py diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 444d1410..a1bdd3bc 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,5 +1,7 @@ """Main entry point for the application.""" +import sys +sys.path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py new file mode 100644 index 00000000..bc360c3d --- /dev/null +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -0,0 +1,32 @@ +""" +Module for getting the assignment files +of a project +""" +import os +from urllib.parse import urljoin + +from flask import jsonify, send_from_directory, send_file +from werkzeug.utils import safe_join + +from flask_restful import Resource + +from project.models.project import Project +from project.utils.query_agent import query_by_id_from_model + +API_URL = os.getenv('API_HOST') +RESPONSE_URL = urljoin(API_URL, "projects") +UPLOAD_FOLDER = os.getenv('UPLOAD_URL') + +class ProjectAssignmentFiles(Resource): + """ + Class for getting the assignment files of a project + """ + def get(self, project_id): + + project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + + file_url = urljoin(f"{UPLOAD_FOLDER}", f"{project_id}") + "/" + + directory = safe_join(os.getcwd(), file_url) + + return send_from_directory(directory, project.assignment_file, as_attachment=True) \ No newline at end of file diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 85d7b99c..fc2008aa 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -14,10 +14,10 @@ patch_by_id_from_model - API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") + class ProjectDetail(Resource): """ Class for projects/id endpoints diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index 09938878..0c4eee20 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -7,6 +7,7 @@ from project.endpoints.projects.projects import ProjectsEndpoint from project.endpoints.projects.project_detail import ProjectDetail +from project.endpoints.projects.project_assignment_file import ProjectAssignmentFiles project_bp = Blueprint('project_endpoint', __name__) @@ -19,3 +20,8 @@ '/projects/', view_func=ProjectDetail.as_view('project_detail') ) + +project_bp.add_url_rule( + '/projects//assignments', + view_func=ProjectAssignmentFiles.as_view('project_assignments') +) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index c394a85d..88752808 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -30,7 +30,6 @@ def get(self): Get method for listing all available projects that are currently in the API """ - response_url = urljoin(API_URL, "projects") return query_selected_from_model( Project, @@ -49,6 +48,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() filename = os.path.split(file.filename)[1] + project_json["assignment_file"] = filename # save the file that is given with the request try: From c700de177170deda37a17856822d5dedb0abc1b6 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:02:21 +0100 Subject: [PATCH 185/377] reformatting --- backend/project/endpoints/projects/project_assignment_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index bc360c3d..9bdc5b11 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -25,7 +25,7 @@ def get(self, project_id): project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - file_url = urljoin(f"{UPLOAD_FOLDER}", f"{project_id}") + "/" + file_url = safe_join(f"{UPLOAD_FOLDER}", f"{project_id}") directory = safe_join(os.getcwd(), file_url) From 8582a6c88ba4fb94bfa7682570bb942d70f3c8fe Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:09:37 +0100 Subject: [PATCH 186/377] linter --- backend/project/__main__.py | 4 ++-- .../project/endpoints/projects/project_assignment_file.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index a1bdd3bc..eaed97f7 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,7 +1,7 @@ """Main entry point for the application.""" -import sys +# import sys -sys.path.append(".") +# sys.path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 9bdc5b11..d405fc8b 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -5,13 +5,12 @@ import os from urllib.parse import urljoin -from flask import jsonify, send_from_directory, send_file +from flask import send_from_directory from werkzeug.utils import safe_join from flask_restful import Resource from project.models.project import Project -from project.utils.query_agent import query_by_id_from_model API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -22,6 +21,9 @@ class ProjectAssignmentFiles(Resource): Class for getting the assignment files of a project """ def get(self, project_id): + """ + Get the assignment files of a project + """ project = Project.query.filter(getattr(Project, "project_id") == project_id).first() @@ -29,4 +31,4 @@ def get(self, project_id): directory = safe_join(os.getcwd(), file_url) - return send_from_directory(directory, project.assignment_file, as_attachment=True) \ No newline at end of file + return send_from_directory(directory, project.assignment_file, as_attachment=True) From cab65e1ddc6c5bd4e4c3f08036fe828435240253 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:13:26 +0100 Subject: [PATCH 187/377] niets veranderd? linter flipt --- backend/project/__main__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index eaed97f7..444d1410 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,7 +1,5 @@ """Main entry point for the application.""" -# import sys -# sys.path.append(".") from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url From 695514ece2134aa90a08c826c1c48340627aec1e Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:17:12 +0100 Subject: [PATCH 188/377] small newline change --- backend/project/endpoints/projects/project_detail.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index fc2008aa..df4e99d7 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -17,7 +17,6 @@ API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") - class ProjectDetail(Resource): """ Class for projects/id endpoints From 0e5bc5975f41cdc6cfa1bfc9ca0a613e86dff90d Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:54:00 +0100 Subject: [PATCH 189/377] removed f-string formatting --- backend/project/endpoints/projects/project_assignment_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index d405fc8b..108ac9e6 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -27,7 +27,7 @@ def get(self, project_id): project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - file_url = safe_join(f"{UPLOAD_FOLDER}", f"{project_id}") + file_url = safe_join(UPLOAD_FOLDER, project_id) directory = safe_join(os.getcwd(), file_url) From 5514ca26b62f8397400c7156a74d9a50c51aa994 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 16:56:25 +0100 Subject: [PATCH 190/377] added 404 and 500 cases --- .../endpoints/projects/project_assignment_file.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 108ac9e6..14f7d014 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -24,9 +24,18 @@ def get(self, project_id): """ Get the assignment files of a project """ - - project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - + try: + project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + if project is None: + return { + "message": "Project not found", + "url": RESPONSE_URL, + }, 404 + except SQLAlchemyError: + return { + "message": "Something went wrong querying the project", + "url": RESPONSE_URL + }, 500 file_url = safe_join(UPLOAD_FOLDER, project_id) directory = safe_join(os.getcwd(), file_url) From 5e37b9fa1dccd4c294c9e379ba40c9d23fc83fcd Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 21:44:12 +0100 Subject: [PATCH 191/377] fixed tests --- .../projects/project_assignment_file.py | 2 +- .../project/endpoints/projects/projects.py | 3 +- backend/tests/endpoints/project_test.py | 36 +++++++++++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 14f7d014..e81239e3 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -36,7 +36,7 @@ def get(self, project_id): "message": "Something went wrong querying the project", "url": RESPONSE_URL }, 500 - file_url = safe_join(UPLOAD_FOLDER, project_id) + file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") directory = safe_join(os.getcwd(), file_url) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 88752808..c30c0886 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -63,7 +63,8 @@ def post(self): "visible_for_students", "archived"] ) - except SQLAlchemyError: + except SQLAlchemyError as e: + print(e) return jsonify({"error": "Something went wrong while inserting into the database.", "url": f"{API_URL}/projects"}), 500 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 906ca596..f15fc9cd 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,6 +1,40 @@ """Tests for project endpoints.""" from project.models.project import Project +def test_assignment_download(db_session, client, course_ad, course_teacher_ad, project_json): + """ + Method for assignment download + """ + db_session.add(course_teacher_ad) + db_session.commit() + + db_session.add(course_ad) + db_session.commit() + project_json["course_id"] = course_ad.course_id + + with open("testzip.zip", "rb") as zip_file: + project_json["assignment_file"] = zip_file + # post the project + response = client.post( + "/projects", + data=project_json, + content_type='multipart/form-data' + ) + + project_id = response.json["data"]["project_id"] + response = client.get(f"/projects/{project_id}/assignments") + # file downloaded succesfully + assert response.status_code == 200 + + +def test_not_found_download(db_session, client, project_json): + response = client.get("/projects") + # get an index that doesnt exist + project_id = len(response.data)+1 + response = client.get(f"/projects/{project_id}/assignments") + assert response.status_code == 404 + + def test_projects_home(client): """Test home project endpoint.""" response = client.get("/projects") @@ -60,8 +94,6 @@ def test_remove_project(db_session, client, course_ad, course_teacher_ad, projec response = client.post("/projects", data=project_json) # check if the project with the id is present - print("joink") - print(response) project_id = response.json["data"]["project_id"] response = client.delete(f"/projects/{project_id}") From 54a3e1ea6be0d88bd5875fd5acfc5f2749c14ebc Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 22:29:16 +0100 Subject: [PATCH 192/377] fixed os.curcwd --- .../project/endpoints/projects/project_assignment_file.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index e81239e3..606f24ea 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -5,7 +5,7 @@ import os from urllib.parse import urljoin -from flask import send_from_directory +from flask import send_from_directory, send_file from werkzeug.utils import safe_join from flask_restful import Resource @@ -38,6 +38,5 @@ def get(self, project_id): }, 500 file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") - directory = safe_join(os.getcwd(), file_url) - - return send_from_directory(directory, project.assignment_file, as_attachment=True) + # return send_from_directory(directory, project.assignment_file, as_attachment=True) + return send_from_directory(file_url,project.assignment_file) From 070ec2ebccf762c869cd3e92c9bbe4934cfbf9a2 Mon Sep 17 00:00:00 2001 From: gerwoud Date: Wed, 13 Mar 2024 22:31:45 +0100 Subject: [PATCH 193/377] linter --- .../project/endpoints/projects/project_assignment_file.py | 3 ++- backend/tests/endpoints/project_test.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 606f24ea..80c6f54e 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -5,8 +5,9 @@ import os from urllib.parse import urljoin -from flask import send_from_directory, send_file +from flask import send_from_directory from werkzeug.utils import safe_join +from sqlalchemy.exc import SQLAlchemyError from flask_restful import Resource diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index f15fc9cd..f8949ff6 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -27,7 +27,10 @@ def test_assignment_download(db_session, client, course_ad, course_teacher_ad, p assert response.status_code == 200 -def test_not_found_download(db_session, client, project_json): +def test_not_found_download(client): + """ + Test a not present project download + """ response = client.get("/projects") # get an index that doesnt exist project_id = len(response.data)+1 From 5c719883569474a5b2fc53d0c4b88371e35b308f Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:20:49 +0100 Subject: [PATCH 194/377] removed print --- backend/project/endpoints/projects/projects.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index c30c0886..88752808 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -63,8 +63,7 @@ def post(self): "visible_for_students", "archived"] ) - except SQLAlchemyError as e: - print(e) + except SQLAlchemyError: return jsonify({"error": "Something went wrong while inserting into the database.", "url": f"{API_URL}/projects"}), 500 From 9a0d99dd5eb87adfecc34370bdceb8f8ec6a093a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:26:22 +0100 Subject: [PATCH 195/377] check if file exist and commented code removed --- .../endpoints/projects/project_assignment_file.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 80c6f54e..9e8f2973 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -37,7 +37,14 @@ def get(self, project_id): "message": "Something went wrong querying the project", "url": RESPONSE_URL }, 500 + file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") - # return send_from_directory(directory, project.assignment_file, as_attachment=True) + if not os.path.isfile(file_url): + # no file is found so return 404 + return { + "message": "No assignment file found for this project", + "url": file_url + }, 404 + return send_from_directory(file_url,project.assignment_file) From 13e32fc7410f6b161c65fd2a36c6b8895e73acfa Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:43:07 +0100 Subject: [PATCH 196/377] fixed code duplication --- .../projects/project_assignment_file.py | 17 ++++++++++++----- backend/project/utils/query_agent.py | 4 ++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 9e8f2973..5ab1b60e 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -12,6 +12,7 @@ from flask_restful import Resource from project.models.project import Project +from project.utils.query_agent import query_by_id_from_model API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -25,8 +26,9 @@ def get(self, project_id): """ Get the assignment files of a project """ - try: - project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + '''try: + # project = Project.query.filter(getattr(Project, "project_id") == project_id).first() + project = query_by_id_from_mode(Project, "project_id", project_id, f"RESPONSE_URL/{project_id}/assignments") if project is None: return { "message": "Project not found", @@ -36,15 +38,20 @@ def get(self, project_id): return { "message": "Something went wrong querying the project", "url": RESPONSE_URL - }, 500 + }, 500''' + json, status_code = query_by_id_from_model(Project, "project_id", project_id, f"RESPONSE_URL") + if status_code != 200: + return json, status_code + + project = json["data"] file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") - if not os.path.isfile(file_url): + if not os.path.isfile(safe_join(file_url, project.assignment_file)): # no file is found so return 404 return { "message": "No assignment file found for this project", "url": file_url }, 404 - return send_from_directory(file_url,project.assignment_file) + return send_from_directory(file_url, project.assignment_file) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index bcdf1ea0..745006a1 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -191,10 +191,10 @@ def query_by_id_from_model(model: DeclarativeMeta, result: Query = model.query.filter(getattr(model, column_name) == column_id).first() if not result: return {"message": "Resource not found", "url": base_url}, 404 - return jsonify({ + return { "data": result, "message": "Resource fetched correctly", - "url": urljoin(f"{base_url}/", str(column_id))}), 200 + "url": urljoin(f"{base_url}/", str(column_id))}, 200 except SQLAlchemyError: return { "error": "Something went wrong while querying the database.", From 0045e1f9d324bbe706b5e17ef1ee4099afa4e39a Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:50:34 +0100 Subject: [PATCH 197/377] used basename insteal of .split --- .../endpoints/projects/project_assignment_file.py | 15 +-------------- backend/project/endpoints/projects/projects.py | 2 +- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 5ab1b60e..4ed8b20d 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -26,26 +26,13 @@ def get(self, project_id): """ Get the assignment files of a project """ - '''try: - # project = Project.query.filter(getattr(Project, "project_id") == project_id).first() - project = query_by_id_from_mode(Project, "project_id", project_id, f"RESPONSE_URL/{project_id}/assignments") - if project is None: - return { - "message": "Project not found", - "url": RESPONSE_URL, - }, 404 - except SQLAlchemyError: - return { - "message": "Something went wrong querying the project", - "url": RESPONSE_URL - }, 500''' json, status_code = query_by_id_from_model(Project, "project_id", project_id, f"RESPONSE_URL") if status_code != 200: return json, status_code project = json["data"] - file_url = safe_join(UPLOAD_FOLDER, f"{project_id}") + file_url = safe_join(UPLOAD_FOLDER, str(project_id)) if not os.path.isfile(safe_join(file_url, project.assignment_file)): # no file is found so return 404 diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 88752808..eabd29f9 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -47,7 +47,7 @@ def post(self): file = request.files["assignment_file"] project_json = parse_project_params() - filename = os.path.split(file.filename)[1] + filename = os.path.basename(file.filename) project_json["assignment_file"] = filename # save the file that is given with the request From 3fbb041d7ee4fbabe24c2f1784fc34603a9ed74b Mon Sep 17 00:00:00 2001 From: gerwoud Date: Thu, 14 Mar 2024 14:56:15 +0100 Subject: [PATCH 198/377] linter fix --- .../project/endpoints/projects/project_assignment_file.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 4ed8b20d..61447c94 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -7,7 +7,6 @@ from flask import send_from_directory from werkzeug.utils import safe_join -from sqlalchemy.exc import SQLAlchemyError from flask_restful import Resource @@ -26,7 +25,12 @@ def get(self, project_id): """ Get the assignment files of a project """ - json, status_code = query_by_id_from_model(Project, "project_id", project_id, f"RESPONSE_URL") + json, status_code = query_by_id_from_model( + Project, + "project_id", + project_id, + "RESPONSE_URL" + ) if status_code != 200: return json, status_code From a2e9c32e823643c56fd083644be0fe71e352462c Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 14 Mar 2024 16:42:52 +0100 Subject: [PATCH 199/377] Backend/tests/review models (#83) * #81 - Updated user model tests, TODO delete * #81 - Removing an unnecessary file * #81 - Updated course model tests, TODO delete * #81 - Updated course_relation model tests * #81 - Cleanup * #81 - Project model tests * #81 - Submission model tests * #81 - Cleanup * #81 - Adding a test and also testing GH tests out * #81 - Adding rollbacks after every error raised * #81 - Maybe fix * #81 - Another try * #81 - This should fix it * #81 Adding the delete method tests and updating the db_construct --- backend/db_construct.sql | 4 +- backend/project/models/course_relation.py | 12 +- backend/project/models/project.py | 6 +- backend/project/models/submission.py | 2 +- backend/project/models/user.py | 12 +- backend/tests/conftest.py | 134 +++++++++++++++- backend/tests/endpoints/conftest.py | 121 +------------- backend/tests/models/__index__.py | 0 backend/tests/models/conftest.py | 84 ---------- backend/tests/models/course_relation_test.py | 83 ++++++++++ backend/tests/models/course_test.py | 149 +++++++----------- backend/tests/models/project_test.py | 79 ++++++++++ .../models/projects_and_submissions_test.py | 63 -------- backend/tests/models/submission_test.py | 92 +++++++++++ backend/tests/models/user_test.py | 57 +++++++ backend/tests/models/users_test.py | 25 --- 16 files changed, 520 insertions(+), 403 deletions(-) delete mode 100644 backend/tests/models/__index__.py delete mode 100644 backend/tests/models/conftest.py create mode 100644 backend/tests/models/course_relation_test.py create mode 100644 backend/tests/models/project_test.py delete mode 100644 backend/tests/models/projects_and_submissions_test.py create mode 100644 backend/tests/models/submission_test.py create mode 100644 backend/tests/models/user_test.py delete mode 100644 backend/tests/models/users_test.py diff --git a/backend/db_construct.sql b/backend/db_construct.sql index e18f7782..a1ad51fe 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -10,7 +10,7 @@ CREATE TABLE courses ( name VARCHAR(50) NOT NULL, ufora_id VARCHAR(50), teacher VARCHAR(255) NOT NULL, - CONSTRAINT fk_teacher FOREIGN KEY(teacher) REFERENCES users(uid), + CONSTRAINT fk_teacher FOREIGN KEY(teacher) REFERENCES users(uid) ON DELETE CASCADE, PRIMARY KEY(course_id) ); @@ -61,7 +61,7 @@ CREATE TABLE submissions ( submission_status BOOLEAN NOT NULL, PRIMARY KEY(submission_id), CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, - CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) + CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) ON DELETE CASCADE ); CREATE OR REPLACE FUNCTION remove_expired_codes() diff --git a/backend/project/models/course_relation.py b/backend/project/models/course_relation.py index 41b7d40b..7c27af31 100644 --- a/backend/project/models/course_relation.py +++ b/backend/project/models/course_relation.py @@ -1,5 +1,6 @@ -"""Models for relation between users and courses""" -from sqlalchemy import Integer, Column, ForeignKey, PrimaryKeyConstraint, String +"""Course relation model""" + +from sqlalchemy import Integer, Column, ForeignKey, String from project.db_in import db class BaseCourseRelation(db.Model): @@ -10,11 +11,8 @@ class BaseCourseRelation(db.Model): __abstract__ = True - course_id = Column(Integer, ForeignKey('courses.course_id'), nullable=False) - uid = Column(String(255), ForeignKey("users.uid"), nullable=False) - __table_args__ = ( - PrimaryKeyConstraint("course_id", "uid"), - ) + course_id = Column(Integer, ForeignKey('courses.course_id'), primary_key=True) + uid = Column(String(255), ForeignKey("users.uid"), primary_key=True) class CourseAdmin(BaseCourseRelation): """Admin to course relation model""" diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 0ed6c495..8ba901ff 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -1,10 +1,10 @@ -"""Model for projects""" -import dataclasses +"""Project model""" +from dataclasses import dataclass from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text from project.db_in import db -@dataclasses.dataclass +@dataclass class Project(db.Model): # pylint: disable=too-many-instance-attributes """This class describes the projects table, a projects has an id, a title, a description, diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index e2309eea..cda2620d 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -1,4 +1,4 @@ -"""Model for submissions""" +"""Submission model""" from dataclasses import dataclass from sqlalchemy import Column, String, ForeignKey, Integer, CheckConstraint, DateTime, Boolean diff --git a/backend/project/models/user.py b/backend/project/models/user.py index 7597462b..bb130349 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -1,10 +1,10 @@ -"""Model for users""" -import dataclasses +"""User model""" +from dataclasses import dataclass from sqlalchemy import Boolean, Column, String from project.db_in import db -@dataclasses.dataclass +@dataclass class User(db.Model): """This class defines the users table, a user has a uid, @@ -12,6 +12,6 @@ class User(db.Model): can be either a student,admin or teacher""" __tablename__ = "users" - uid:str = Column(String(255), primary_key=True) - is_teacher:bool = Column(Boolean) - is_admin:bool = Column(Boolean) + uid: str = Column(String(255), primary_key=True) + is_teacher: bool = Column(Boolean) + is_admin: bool = Column(Boolean) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0ff5b009..aebe7ce9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,15 @@ -"""root level fixtures""" +"""Root level fixtures""" + +from datetime import datetime +from zoneinfo import ZoneInfo import pytest from project.sessionmaker import engine, Session from project.db_in import db +from project.models.course import Course +from project.models.user import User +from project.models.project import Project +from project.models.course_relation import CourseStudent,CourseAdmin +from project.models.submission import Submission @pytest.fixture def db_session(): @@ -22,3 +30,127 @@ def db_session(): for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() + +def users(): + """Return a list of users to populate the database""" + return [ + User(uid="brinkmann", is_admin=True, is_teacher=True), + User(uid="laermans", is_admin=True, is_teacher=True), + User(uid="student01", is_admin=False, is_teacher=False), + User(uid="student02", is_admin=False, is_teacher=False) + ] + +def courses(): + """Return a list of courses to populate the database""" + return [ + Course(name="AD3", teacher="brinkmann"), + Course(name="RAF", teacher="laermans"), + ] + +def course_relations(session): + """Returns a list of course relations to populate the database""" + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id + + return [ + CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), + CourseStudent(course_id=course_id_ad3, uid="student01"), + CourseStudent(course_id=course_id_ad3, uid="student02"), + CourseAdmin(course_id=course_id_raf, uid="laermans"), + CourseStudent(course_id=course_id_raf, uid="student02") + ] + +def projects(session): + """Return a list of projects to populate the database""" + course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id + course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id + + return [ + Project( + title="B+ Trees", + description="Implement B+ trees", + assignment_file="assignement.pdf", + deadline=datetime(2024,3,15,13,0,0), + course_id=course_id_ad3, + visible_for_students=True, + archived=False, + test_path="/tests", + script_name="script.sh", + regex_expressions=["solution"] + ), + Project( + title="Predicaten", + description="Predicaten project", + assignment_file="assignment.pdf", + deadline=datetime(2023,3,15,13,0,0), + course_id=course_id_raf, + visible_for_students=False, + archived=True, + test_path="/tests", + script_name="script.sh", + regex_expressions=[".*"] + ) + ] + +def submissions(session): + """Return a list of submissions to populate the database""" + project_id_ad3 = session.query(Project).filter_by(title="B+ Trees").first().project_id + project_id_raf = session.query(Project).filter_by(title="Predicaten").first().project_id + + return [ + Submission( + uid="student01", + project_id=project_id_ad3, + grading=16, + submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), + submission_path="/submissions/1", + submission_status=True + ), + Submission( + uid="student02", + project_id=project_id_ad3, + submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), + submission_path="/submissions/2", + submission_status=False + ), + Submission( + uid="student02", + project_id=project_id_raf, + grading=15, + submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), + submission_path="/submissions/3", + submission_status=True + ) + ] + +@pytest.fixture +def session(): + """Create a new database session for a test. + After the test, all changes are rolled back and the session is closed.""" + + db.metadata.create_all(engine) + session = Session() + + try: + # Populate the database + session.add_all(users()) + session.commit() + session.add_all(courses()) + session.commit() + session.add_all(course_relations(session)) + session.commit() + session.add_all(projects(session)) + session.commit() + session.add_all(submissions(session)) + session.commit() + + yield session + finally: + # Rollback + session.rollback() + session.close() + + # Truncate all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 7c71842e..e87aed95 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,110 +3,15 @@ import tempfile import os from datetime import datetime -from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine - -from project.models.course import Course +from project import create_app_with_db +from project.db_in import db, url from project.models.user import User -from project.models.project import Project +from project.models.course import Course from project.models.course_relation import CourseStudent,CourseAdmin from project.models.course_share_code import CourseShareCode -from project import create_app_with_db -from project.db_in import url, db -from project.models.submission import Submission - -def users(): - """Return a list of users to populate the database""" - return [ - User(uid="brinkmann", is_admin=True, is_teacher=True), - User(uid="laermans", is_admin=True, is_teacher=True), - User(uid="student01", is_admin=False, is_teacher=False), - User(uid="student02", is_admin=False, is_teacher=False) - ] - -def courses(): - """Return a list of courses to populate the database""" - return [ - Course(name="AD3", teacher="brinkmann"), - Course(name="RAF", teacher="laermans"), - ] - -def course_relations(session): - """Returns a list of course relations to populate the database""" - course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id - course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id - - return [ - CourseAdmin(course_id=course_id_ad3, uid="brinkmann"), - CourseStudent(course_id=course_id_ad3, uid="student01"), - CourseStudent(course_id=course_id_ad3, uid="student02"), - CourseAdmin(course_id=course_id_raf, uid="laermans"), - CourseStudent(course_id=course_id_raf, uid="student02") - ] - -def projects(session): - """Return a list of projects to populate the database""" - course_id_ad3 = session.query(Course).filter_by(name="AD3").first().course_id - course_id_raf = session.query(Course).filter_by(name="RAF").first().course_id - - return [ - Project( - title="B+ Trees", - description="Implement B+ trees", - assignment_file="assignement.pdf", - deadline=datetime(2024,3,15,13,0,0), - course_id=course_id_ad3, - visible_for_students=True, - archived=False, - test_path="/tests", - script_name="script.sh", - regex_expressions=["solution"] - ), - Project( - title="Predicaten", - description="Predicaten project", - assignment_file="assignment.pdf", - deadline=datetime(2023,3,15,13,0,0), - course_id=course_id_raf, - visible_for_students=False, - archived=True, - test_path="/tests", - script_name="script.sh", - regex_expressions=[".*"] - ) - ] - -def submissions(session): - """Return a list of submissions to populate the database""" - project_id_ad3 = session.query(Project).filter_by(title="B+ Trees").first().project_id - project_id_raf = session.query(Project).filter_by(title="Predicaten").first().project_id - - return [ - Submission( - uid="student01", - project_id=project_id_ad3, - grading=16, - submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), - submission_path="/submissions/1", - submission_status=True - ), - Submission( - uid="student02", - project_id=project_id_ad3, - submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), - submission_path="/submissions/2", - submission_status=False - ), - Submission( - uid="student02", - project_id=project_id_raf, - grading=15, - submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), - submission_path="/submissions/3", - submission_status=True - ) - ] +from project.models.project import Project @pytest.fixture def file_empty(): @@ -137,24 +42,6 @@ def files(): with open(name02, "rb") as temp02: yield [(temp01, name01), (temp02, name02)] -@pytest.fixture -def session(db_session): - """Create a database session for the tests""" - # Populate the database - db_session.add_all(users()) - db_session.commit() - db_session.add_all(courses()) - db_session.commit() - db_session.add_all(course_relations(db_session)) - db_session.commit() - db_session.add_all(projects(db_session)) - db_session.commit() - db_session.add_all(submissions(db_session)) - db_session.commit() - - # Tests can now use a populated database - yield db_session - @pytest.fixture def app(): """A fixture that creates and configures a new app instance for each test. diff --git a/backend/tests/models/__index__.py b/backend/tests/models/__index__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/tests/models/conftest.py b/backend/tests/models/conftest.py deleted file mode 100644 index dbc0dc19..00000000 --- a/backend/tests/models/conftest.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Configuration for the models tests. Contains all the fixtures needed for multiple models tests. -""" - -from datetime import datetime -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -import pytest -from project.models.course import Course -from project.models.course_relation import CourseAdmin, CourseStudent -from project.models.project import Project -from project.models.user import User -from project.db_in import url - -engine = create_engine(url) -Session = sessionmaker(bind=engine) - - -@pytest.fixture -def valid_user(): - """A valid user for testing""" - user = User(uid="student", is_teacher=False, is_admin=False) - return user - -@pytest.fixture -def teachers(): - """A list of 10 teachers for testing""" - users = [User(uid=str(i), is_teacher=True, is_admin=False) for i in range(10)] - return users - -@pytest.fixture -def course_teacher(): - """A user that's a teacher for for testing""" - sel2_teacher = User(uid="Bart", is_teacher=True, is_admin=False) - return sel2_teacher - -@pytest.fixture -def course(course_teacher): - """A course for testing, with the course teacher as the teacher.""" - sel2 = Course(name="Sel2", teacher=course_teacher.uid) - return sel2 - -@pytest.fixture -def course_students(): - """A list of 5 students for testing.""" - students = [ - User(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) - for i in range(5) - ] - return students - -@pytest.fixture -def course_students_relation(course,course_students): - """A list of 5 course relations for testing.""" - course_relations = [ - CourseStudent(course_id=course.course_id, uid=course_students[i].uid) - for i in range(5) - ] - return course_relations - -@pytest.fixture -def assistent(): - """An assistent for testing.""" - assist = User(uid="assistent_sel2") - return assist - -@pytest.fixture() -def course_admin(course,assistent): - """A course admin for testing.""" - admin_relation = CourseAdmin(uid=assistent.uid, course_id=course.course_id) - return admin_relation - -@pytest.fixture() -def valid_project(): - """A valid project for testing.""" - deadline = datetime(2024, 2, 25, 12, 0, 0) # February 25, 2024, 12:00 PM - project = Project( - title="Project", - description="Test project", - deadline=deadline, - visible_for_students=True, - archived=False, - ) - return project diff --git a/backend/tests/models/course_relation_test.py b/backend/tests/models/course_relation_test.py new file mode 100644 index 00000000..ba8fa238 --- /dev/null +++ b/backend/tests/models/course_relation_test.py @@ -0,0 +1,83 @@ +"""Course relation tests""" + +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.course import Course +from project.models.course_relation import CourseAdmin + +class TestCourseRelationModel: + """Class to test the CourseRelation model""" + + def test_create_course_relation(self, session: Session): + """Test if a course relation can be created""" + course_id = session.query(Course).filter_by(name="AD3").first().course_id + relation = CourseAdmin(course_id=course_id, uid="laermans") + session.add(relation) + session.commit() + assert session.get(CourseAdmin, (course_id, "laermans")) is not None + + def test_query_course_relation(self, session: Session): + """Test if a course relation can be queried""" + assert session.query(CourseAdmin).count() == 2 + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + assert relation is not None + assert relation.course_id == \ + session.query(Course).filter_by(name="AD3").first().course_id + + def test_update_course_relation(self, session: Session): + """Test if a course relation can be updated""" + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + course = session.query(Course).filter_by(name="RAF").first() + relation.course_id = course.course_id + session.commit() + assert session.get(CourseAdmin, (course.course_id, "brinkmann")) is not None + + def test_delete_course_relation(self, session: Session): + """Test if a course relation can be deleted""" + relation = session.query(CourseAdmin).first() + session.delete(relation) + session.commit() + assert session.get(CourseAdmin, (relation.course_id,relation.uid)) is None + assert session.query(CourseAdmin).count() == 1 + + def test_primary_key(self, session: Session): + """Test the primary key""" + relations = session.query(CourseAdmin).all() + with raises(IntegrityError): + relations[0].course_id = relations[1].course_id + relations[0].uid = relations[1].uid + session.commit() + session.rollback() + + def test_foreign_key_course_id(self, session: Session): + """Test the foreign key course_id""" + course = session.query(Course).filter_by(name="RAF").first() + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + relation.course_id = course.course_id + session.commit() + assert session.get(CourseAdmin, (course.course_id, "brinkmann")) is not None + with raises(IntegrityError): + relation.course_id = 0 + session.commit() + session.rollback() + + def test_foreign_key_uid(self, session: Session): + """Test the foreign key uid""" + relation = session.query(CourseAdmin).filter_by(uid="brinkmann").first() + relation.uid = "laermans" + session.commit() + assert session.get(CourseAdmin, (relation.course_id,relation.uid)) is not None + with raises(IntegrityError): + relation.uid = "unknown" + session.commit() + session.rollback() + + @mark.parametrize("property_name", ["course_id", "uid"]) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + relation = session.query(CourseAdmin).first() + with raises(IntegrityError): + setattr(relation, property_name, None) + session.commit() + session.rollback() diff --git a/backend/tests/models/course_test.py b/backend/tests/models/course_test.py index 6c286d7c..3c17ecad 100644 --- a/backend/tests/models/course_test.py +++ b/backend/tests/models/course_test.py @@ -1,98 +1,59 @@ -"""Test module for the Course model""" -import pytest +"""Course model tests""" + +from pytest import raises, mark +from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from project.models.course import Course -from project.models.user import User -from project.models.course_relation import CourseAdmin, CourseStudent - class TestCourseModel: - """Test class for the database models""" - - def test_foreignkey_courses_teacher(self, db_session, course: Course): - """Tests the foreign key relation between courses and the teacher uid""" - with pytest.raises( - IntegrityError - ): - db_session.add(course) - db_session.commit() - - def test_correct_course(self, db_session, course: Course, course_teacher: User): - """Tests wether added course and a teacher are correctly connected""" - db_session.add(course_teacher) - db_session.commit() - - db_session.add(course) - db_session.commit() - assert ( - db_session.query(Course).filter_by(name=course.name).first().teacher - == course_teacher.uid - ) - - def test_foreignkey_coursestudents_uid( - self, db_session, course, course_teacher, course_students_relation - ): - """Test the foreign key of the CourseStudent related to the student uid""" - db_session.add(course_teacher) - db_session.commit() - - db_session.add(course) - db_session.commit() - for s in course_students_relation: - s.course_id = course.course_id - - with pytest.raises( - IntegrityError - ): - db_session.add_all(course_students_relation) - db_session.commit() - - def test_correct_courserelations( # pylint: disable=too-many-arguments ; all arguments are needed for the test - self, - db_session, - course, - course_teacher, - course_students, - course_students_relation, - assistent, - course_admin, - ): - """Tests if we get the expected results for - correct usage of CourseStudent and CourseAdmin""" - - db_session.add(course_teacher) - db_session.commit() - - db_session.add(course) - db_session.commit() - - db_session.add_all(course_students) - db_session.commit() - - for s in course_students_relation: - s.course_id = course.course_id - db_session.add_all(course_students_relation) - db_session.commit() - - student_check = [ - s.uid - for s in db_session.query(CourseStudent) - .filter_by(course_id=course.course_id) - .all() - ] - student_uids = [s.uid for s in course_students] - assert student_check == student_uids - - db_session.add(assistent) - db_session.commit() - course_admin.course_id = course.course_id - db_session.add(course_admin) - db_session.commit() - - assert ( - db_session.query(CourseAdmin) - .filter_by(course_id=course.course_id) - .first() - .uid - == assistent.uid - ) + """Class to test the Course model""" + + def test_create_course(self, session: Session): + """Test if a course can be created""" + course = Course(name="SEL2", ufora_id="C003784A_2023", teacher="brinkmann") + session.add(course) + session.commit() + assert session.get(Course, course.course_id) is not None + assert session.query(Course).count() == 3 + + def test_query_course(self, session: Session): + """Test if a course can be queried""" + assert session.query(Course).count() == 2 + course = session.query(Course).filter_by(name="AD3").first() + assert course is not None + assert course.teacher == "brinkmann" + + def test_update_course(self, session: Session): + """Test if a course can be updated""" + course = session.query(Course).filter_by(name="AD3").first() + course.name = "AD2" + session.commit() + assert session.get(Course, course.course_id).name == "AD2" + + def test_delete_course(self, session: Session): + """Test if a course can be deleted""" + course = session.query(Course).first() + session.delete(course) + session.commit() + assert session.get(Course, course.course_id) is None + assert session.query(Course).count() == 1 + + def test_foreign_key_teacher(self, session: Session): + """Test the foreign key teacher""" + course = session.query(Course).filter_by(name="AD3").first() + course.teacher = "laermans" + session.commit() + assert session.get(Course, course.course_id).teacher == "laermans" + with raises(IntegrityError): + course.teacher = "unknown" + session.commit() + session.rollback() + + @mark.parametrize("property_name", ["name","teacher"]) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + course = session.query(Course).first() + with raises(IntegrityError): + setattr(course, property_name, None) + session.commit() + session.rollback() diff --git a/backend/tests/models/project_test.py b/backend/tests/models/project_test.py new file mode 100644 index 00000000..b99a6134 --- /dev/null +++ b/backend/tests/models/project_test.py @@ -0,0 +1,79 @@ +"""Project model tests""" + +from datetime import datetime +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.course import Course +from project.models.project import Project + +class TestProjectModel: + """Class to test the Project model""" + + def test_create_project(self, session: Session): + """Test if a project can be created""" + course = session.query(Course).first() + project = Project( + title="Pigeonhole", + description="A new project", + assignment_file="assignment.pdf", + deadline=datetime(2024,12,31,23,59,59), + course_id=course.course_id, + visible_for_students=True, + archived=False, + test_path="/test", + script_name="script", + regex_expressions=[r".*"] + ) + session.add(project) + session.commit() + assert project.project_id is not None + assert session.query(Project).count() == 3 + + def test_query_project(self, session: Session): + """Test if a project can be queried""" + assert session.query(Project).count() == 2 + project = session.query(Project).filter_by(title="Predicaten").first() + assert project is not None + assert project.course_id == session.query(Course).filter_by(name="RAF").first().course_id + + def test_update_project(self, session: Session): + """Test if a project can be updated""" + project = session.query(Project).filter_by(title="B+ Trees").first() + project.title = "Trees" + project.description = "Implement 3 trees of your choosing" + session.commit() + updated_project = session.get(Project, project.project_id) + assert updated_project.title == "Trees" + assert updated_project.description == "Implement 3 trees of your choosing" + + def test_delete_project(self, session: Session): + """Test if a project can be deleted""" + project = session.query(Project).first() + session.delete(project) + session.commit() + assert session.get(Project, project.project_id) is None + assert session.query(Project).count() == 1 + + def test_foreign_key_course_id(self, session: Session): + """Test the foreign key course_id""" + course = session.query(Course).filter_by(name="RAF").first() + project = session.query(Project).filter_by(title="B+ Trees").first() + project.course_id = course.course_id + session.commit() + assert project.course_id == course.course_id + with raises(IntegrityError): + project.course_id = 0 + session.commit() + session.rollback() + + @mark.parametrize("property_name", + ["title","description","course_id","visible_for_students","archived"] + ) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + project = session.query(Project).first() + with raises(IntegrityError): + setattr(project, property_name, None) + session.commit() + session.rollback() diff --git a/backend/tests/models/projects_and_submissions_test.py b/backend/tests/models/projects_and_submissions_test.py deleted file mode 100644 index 912d9294..00000000 --- a/backend/tests/models/projects_and_submissions_test.py +++ /dev/null @@ -1,63 +0,0 @@ -"""This module tests the Project and Submission model""" -from datetime import datetime -import pytest -from sqlalchemy.exc import IntegrityError -from project.models.project import Project -from project.models.submission import Submission - -class TestProjectAndSubmissionModel: # pylint: disable=too-few-public-methods - """Test class for the database models of projects and submissions""" - def test_deadline(self,db_session, # pylint: disable=too-many-arguments ; all arguments are needed for the test - course, - course_teacher, - valid_project, - valid_user): - """Tests if the deadline is correctly set - and if the submission is correctly connected to the project""" - db_session.add(course_teacher) - db_session.commit() - db_session.add(course) - db_session.commit() - valid_project.course_id = course.course_id - db_session.add(valid_project) - db_session.commit() - check_project = ( - db_session.query(Project).filter_by(title=valid_project.title).first() - ) - assert check_project.deadline == valid_project.deadline - - db_session.add(valid_user) - db_session.commit() - submission = Submission( - uid=valid_user.uid, - project_id=check_project.project_id, - submission_time=datetime.now(), - submission_path="/test/submission/", - submission_status=False, - ) - db_session.add(submission) - db_session.commit() - - submission_check = ( - db_session.query(Submission) - .filter_by(project_id=check_project.project_id) - .first() - ) - assert submission_check.uid == valid_user.uid - - with pytest.raises( - IntegrityError - ): - submission_check.grading = 100 - db_session.commit() - db_session.rollback() - submission_check.grading = 15 - db_session.commit() - submission_check = ( - db_session.query(Submission) - .filter_by(project_id=check_project.project_id) - .first() - ) - assert submission_check.grading == 15 - assert submission.grading == 15 - # Interesting! all the model objects are connected diff --git a/backend/tests/models/submission_test.py b/backend/tests/models/submission_test.py new file mode 100644 index 00000000..66a2779b --- /dev/null +++ b/backend/tests/models/submission_test.py @@ -0,0 +1,92 @@ +"""Submission model tests""" + +from datetime import datetime +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.project import Project +from project.models.submission import Submission + +class TestSubmissionModel: + """Class to test the Submission model""" + + def test_create_submission(self, session: Session): + """Test if a submission can be created""" + project = session.query(Project).filter_by(title="B+ Trees").first() + submission = Submission( + uid="student01", + project_id=project.project_id, + submission_time=datetime(2023,3,15,13,0,0), + submission_path="/submissions", + submission_status=True + ) + session.add(submission) + session.commit() + assert submission.submission_id is not None + assert session.query(Submission).count() == 4 + + def test_query_submission(self, session: Session): + """Test if a submission can be queried""" + assert session.query(Submission).count() == 3 + submission = session.query(Submission).filter_by(uid="student01").first() + assert submission is not None + + def test_update_submission(self, session: Session): + """Test if a submission can be updated""" + submission = session.query(Submission).filter_by(uid="student01").first() + submission.uid = "student02" + submission.grading = 20 + session.commit() + updated_submission = session.get(Submission, submission.submission_id) + assert updated_submission.uid == "student02" + assert updated_submission.grading == 20 + + def test_delete_submission(self, session: Session): + """Test if a submission can be deleted""" + submission = session.query(Submission).first() + session.delete(submission) + session.commit() + assert session.get(Submission, submission.submission_id) is None + assert session.query(Submission).count() == 2 + + def test_foreign_key_uid(self, session: Session): + """Test the foreign key uid""" + submission = session.query(Submission).filter_by(uid="student01").first() + submission.uid = "student02" + session.commit() + assert submission.uid == "student02" + with raises(IntegrityError): + submission.uid = "unknown" + session.commit() + session.rollback() + + def test_foreign_key_project_id(self, session: Session): + """Test the foreign key project_id""" + submission = session.query(Submission).filter_by(uid="student01").first() + project = session.query(Project).filter_by(title="Predicaten").first() + submission.project_id = project.project_id + session.commit() + assert submission.project_id == project.project_id + with raises(IntegrityError): + submission.project_id = 0 + session.commit() + session.rollback() + + @mark.parametrize("property_name", + ["uid","project_id","submission_time","submission_path","submission_status"] + ) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + submission = session.query(Submission).first() + with raises(IntegrityError): + setattr(submission, property_name, None) + session.commit() + session.rollback() + + def test_grading_constraint(self, session: Session): + """Test if the grading is between 0 and 20""" + submission = session.query(Submission).first() + with raises(IntegrityError): + submission.grading = 80 + session.commit() + session.rollback() diff --git a/backend/tests/models/user_test.py b/backend/tests/models/user_test.py new file mode 100644 index 00000000..8a026711 --- /dev/null +++ b/backend/tests/models/user_test.py @@ -0,0 +1,57 @@ +"""User model tests""" + +from pytest import raises, mark +from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from project.models.user import User + +class TestUserModel: + """Class to test the User model""" + + def test_create_user(self, session: Session): + """Test if a user can be created""" + user = User(uid="user01", is_teacher=False, is_admin=False) + session.add(user) + session.commit() + assert session.get(User, "user01") is not None + assert session.query(User).count() == 5 + + def test_query_user(self, session: Session): + """Test if a user can be queried""" + assert session.query(User).count() == 4 + teacher = session.query(User).filter_by(uid="brinkmann").first() + assert teacher is not None + assert teacher.is_teacher + + def test_update_user(self, session: Session): + """Test if a user can be updated""" + student = session.query(User).filter_by(uid="student01").first() + student.is_admin = True + session.commit() + assert session.get(User, "student01").is_admin + + def test_delete_user(self, session: Session): + """Test if a user can be deleted""" + user = session.query(User).first() + session.delete(user) + session.commit() + assert session.get(User, user.uid) is None + assert session.query(User).count() == 3 + + @mark.parametrize("property_name", ["uid"]) + def test_property_not_nullable(self, session: Session, property_name: str): + """Test if the property is not nullable""" + user = session.query(User).first() + with raises(IntegrityError): + setattr(user, property_name, None) + session.commit() + session.rollback() + + @mark.parametrize("property_name", ["uid"]) + def test_property_unique(self, session: Session, property_name: str): + """Test if the property is unique""" + users = session.query(User).all() + with raises(IntegrityError): + setattr(users[0], property_name, getattr(users[1], property_name)) + session.commit() + session.rollback() diff --git a/backend/tests/models/users_test.py b/backend/tests/models/users_test.py deleted file mode 100644 index 2d77b953..00000000 --- a/backend/tests/models/users_test.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -This file contains the tests for the User model. -""" -from project.models.user import User - - -class TestUserModel: - """Test class for the database models""" - - def test_valid_user(self, db_session, valid_user): - """Tests if a valid user can be added to the database.""" - db_session.add(valid_user) - db_session.commit() - assert valid_user in db_session.query(User).all() - - def test_is_teacher(self, db_session, teachers): - """Tests if the is_teacher field is correctly set to True - for the teachers when added to the database.""" - db_session.add_all(teachers) - db_session.commit() - teacher_count = 0 - for usr in db_session.query(User).filter_by(is_teacher=True): - teacher_count += 1 - assert usr.is_teacher - assert teacher_count == 10 From b1687436f1c177ff9884a3b9c4906bd1753a7bdf Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 14 Mar 2024 19:27:39 +0100 Subject: [PATCH 200/377] Tests/cleanup (#104) * command useless and takes time * cleaned up courses tests * cleaned up project tests * moved testzip to resources folder * cleaned up users tests * small cleanup submission tests * resolved linting * corrected function docs * re-using existing fixtures to reduce code duplication * resolved linter * removed useless fstring * removed unused import --- backend/Dockerfile.test | 3 - backend/tests/endpoints/conftest.py | 221 ++++++++++-------- .../tests/endpoints/course/courses_test.py | 193 +++------------ .../tests/endpoints/course/share_link_test.py | 17 +- backend/tests/endpoints/project_test.py | 81 ++----- backend/tests/endpoints/submissions_test.py | 80 +++---- backend/tests/endpoints/user_test.py | 84 ++++--- backend/{ => tests/resources}/testzip.zip | Bin 8 files changed, 258 insertions(+), 421 deletions(-) rename backend/{ => tests/resources}/testzip.zip (100%) diff --git a/backend/Dockerfile.test b/backend/Dockerfile.test index f975133c..92ec8b63 100644 --- a/backend/Dockerfile.test +++ b/backend/Dockerfile.test @@ -10,6 +10,3 @@ COPY . /app RUN apt-get update RUN apt-get install -y --no-install-recommends python3-pip RUN pip3 install --no-cache-dir -r requirements.txt -r dev-requirements.txt - -# Command to run the tests -CMD ["pytest"] \ No newline at end of file diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index e87aed95..4466ee92 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,16 +3,87 @@ import tempfile import os from datetime import datetime +from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine -from project import create_app_with_db -from project.db_in import db, url from project.models.user import User from project.models.course import Course -from project.models.course_relation import CourseStudent,CourseAdmin from project.models.course_share_code import CourseShareCode +from project import create_app_with_db +from project.db_in import url, db +from project.models.submission import Submission from project.models.project import Project + +@pytest.fixture +def valid_submission(valid_user_entry, valid_project_entry): + """ + Returns a valid submission form + """ + return { + "uid": valid_user_entry.uid, + "project_id": valid_project_entry.project_id, + "grading": 16, + "submission_time": datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), + "submission_path": "/submission/1", + "submission_status": True + } + +@pytest.fixture +def valid_submission_entry(session, valid_submission): + """ + Returns a submission that is in the database + """ + submission = Submission(**valid_submission) + session.add(submission) + session.commit() + return submission + +@pytest.fixture +def valid_user(): + """ + Returns a valid user form + """ + return { + "uid": "w_student", + "is_teacher": False + } + +@pytest.fixture +def valid_user_entry(session, valid_user): + """ + Returns a user that is in the database + """ + user = User(**valid_user) + session.add(user) + session.commit() + return user + +@pytest.fixture +def user_invalid_field(valid_user): + """ + Returns a user form with an invalid field + """ + valid_user["is_student"] = True + return valid_user + +@pytest.fixture +def valid_user_entries(session): + """ + Returns a list of users that are in the database + """ + users = [ + User(uid="del", is_admin=False, is_teacher=True), + User(uid="pat", is_admin=False, is_teacher=True), + User(uid="u_get", is_admin=False, is_teacher=True), + User(uid="query_user", is_admin=True, is_teacher=False)] + + session.add_all(users) + session.commit() + + return users + + @pytest.fixture def file_empty(): """Return an empty file""" @@ -66,36 +137,28 @@ def course_ad(course_teacher_ad: User): return ad2 @pytest.fixture -def project(course): +def valid_project_entry(session, valid_project): """A project for testing, with the course as the course it belongs to""" - date = datetime(2024, 2, 25, 12, 0, 0) - project = Project( - title="Project", - description="Test project", - course_id=course.course_id, - deadline=date, - visible_for_students=True, - archived=False, - test_path="testpad", - script_name="testscript", - regex_expressions='r' - ) + project = Project(**valid_project) + + session.add(project) + session.commit() return project @pytest.fixture -def project_json(project: Project): - """A function that return the json data of a project including the PK needed for testing""" +def valid_project(valid_course_entry): + """A function that return the json form data of a project""" data = { - "title": project.title, - "description": project.description, - "assignment_file": project.assignment_file, - "deadline": project.deadline, - "course_id": project.course_id, - "visible_for_students": project.visible_for_students, - "archived": project.archived, - "test_path": project.test_path, - "script_name": project.script_name, - "regex_expressions": project.regex_expressions + "title": "Project", + "description": "Test project", + "assignment_file": "testfile", + "deadline": "2024-02-25T12:00:00", + "course_id": valid_course_entry.course_id, + "visible_for_students": True, + "archived": False, + "test_path": "tests", + "script_name": "script.sh", + "regex_expressions": ["*.pdf", "*.txt"] } return data @@ -112,88 +175,54 @@ def client(app): yield client @pytest.fixture -def courses_get_db(db_with_course): - """Database equipped for the get tests""" - for x in range(3,10): - course = Course(teacher="Bart", name="Sel" + str(x)) - db_with_course.add(course) - db_with_course.commit() - db_with_course.add(CourseAdmin(course_id=course.course_id,uid="Bart")) - db_with_course.commit() - course = db_with_course.query(Course).filter_by(name="Sel2").first() - db_with_course.add(CourseAdmin(course_id=course.course_id,uid="Rien")) - db_with_course.add_all( - [CourseStudent(course_id=course.course_id, uid="student_sel2_" + str(i)) - for i in range(3)]) - db_with_course.commit() - return db_with_course +def valid_teacher_entry(session): + """A valid teacher for testing that's already in the db""" + teacher = User(uid="Bart", is_teacher=True) + session.add(teacher) + session.commit() + return teacher @pytest.fixture -def db_with_course(courses_init_db): - """A database with a course.""" - courses_init_db.add(Course(name="Sel2", teacher="Bart")) - courses_init_db.commit() - course = courses_init_db.query(Course).filter_by(name="Sel2").first() - courses_init_db.add(CourseAdmin(course_id=course.course_id,uid="Bart")) - courses_init_db.commit() - return courses_init_db +def valid_course(valid_teacher_entry): + """A valid course json form""" + return {"name": "Sel", "teacher": valid_teacher_entry.uid} @pytest.fixture -def course_data(): - """A valid course for testing.""" - return {"name": "Sel2", "teacher": "Bart"} +def course_no_name(valid_teacher_entry): + """A course with no name""" + return {"name": "", "teacher": valid_teacher_entry.uid} @pytest.fixture -def invalid_course(): - """An invalid course for testing.""" - return {"invalid": "error"} +def valid_course_entry(session, valid_course): + """A valid course for testing that's already in the db""" + course = Course(**valid_course) + session.add(course) + session.commit() + return course @pytest.fixture -def courses_init_db(db_session, course_students, course_teacher, course_assistent): - """ - What do we need to test the courses api standalone: - A teacher that can make a new course - and some students - and an assistent - """ - db_session.add_all(course_students) - db_session.add(course_teacher) - db_session.add(course_assistent) - db_session.commit() - return db_session - -@pytest.fixture -def course_students(): - """A list of 5 students for testing.""" +def valid_students_entries(session): + """Valid students for testing that are already in the db""" students = [ - User(uid="student_sel2_" + str(i), is_teacher=False, is_admin=False) - for i in range(5) + User(uid=f"student_sel2_{i}", is_teacher=False) + for i in range(3) ] + session.add_all(students) + session.commit() return students @pytest.fixture -def course_teacher(): - """A user that's a teacher for testing""" - sel2_teacher = User(uid="Bart", is_teacher=True, is_admin=False) - return sel2_teacher - -@pytest.fixture -def course_assistent(): - """A user that's a teacher for testing""" - sel2_assistent = User(uid="Rien", is_teacher=True, is_admin=False) - return sel2_assistent - -@pytest.fixture -def course(course_teacher): - """A course for testing, with the course teacher as the teacher.""" - sel2 = Course(name="Sel2", teacher=course_teacher.uid) - return sel2 +def valid_course_entries(session, valid_teacher_entry): + """A valid course for testing that's already in the db""" + courses = [Course(name=f"Sel{i}", teacher=valid_teacher_entry.uid) for i in range(3)] + session.add_all(courses) + session.commit() + return courses @pytest.fixture -def share_code_admin(db_with_course): +def share_code_admin(session, valid_course_entry): """A course with share codes for testing.""" - course = db_with_course.query(Course).first() - share_code = CourseShareCode(course_id=course.course_id, for_admins=True) - db_with_course.add(share_code) - db_with_course.commit() + share_code = CourseShareCode(course_id=valid_course_entry.course_id, for_admins=True) + session.add(share_code) + session.commit() return share_code diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 5e9fde7d..c9b64e15 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,202 +1,73 @@ """Here we will test all the courses endpoint related functionality""" -from project.models.course_relation import CourseStudent, CourseAdmin - - -from project.models.course import Course - - class TestCourseEndpoint: """Class for testing the courses endpoint""" - def test_post_courses(self, courses_init_db, client, course_data, invalid_course): + def test_post_courses(self, client, valid_course): """ Test posting a course to the /courses endpoint """ - response = client.post("/courses?uid=Bart", json=course_data) # valid user - for x in range(3, 10): - course = {"name": "Sel" + str(x), "teacher": "Bart"} - response = client.post("/courses?uid=Bart", json=course) # valid user - assert response.status_code == 201 - assert response.status_code == 201 # succes post = 201 + response = client.post("/courses", json=valid_course) + assert response.status_code == 201 + data = response.json + assert data["data"]["name"] == "Sel" + assert data["data"]["teacher"] == valid_course["teacher"] - course = courses_init_db.query(Course).filter_by(name="Sel2").first() - assert course is not None - assert course.teacher == "Bart" + # Is reachable using the API + get_response = client.get(f"/courses/{data['data']['course_id']}") + assert get_response.status_code == 200 - response = client.post( - "/courses?uid=Bart", json=invalid_course - ) # invalid course - assert response.status_code == 400 - def test_post_courses_course_id_students_and_admins(self, db_with_course, client): + def test_post_courses_course_id_students_and_admins( + self, client, valid_course_entry, valid_students_entries): """ Test posting to courses/course_id/students and admins """ - course = db_with_course.query(Course).filter_by(name="Sel2").first() - # Posting to /courses/course_id/students and admins test - valid_students = { - "students": ["student_sel2_0", "student_sel2_1", "student_sel2_2"] - } - bad_students = {"error": ["student_sel2_0", "student_sel2_1"]} - sel2_students_link = "/courses/" + str(course.course_id) - response = client.post( - sel2_students_link + "/students?uid=student_sel2_0", - json=valid_students, # unauthorized user - ) - assert response.status_code == 403 - - assert course.teacher == "Bart" - response = client.post( - sel2_students_link + "/students?uid=Bart", - json=valid_students, # authorized user - ) - - assert response.status_code == 201 # succes post = 201 - users = [ - s.uid - for s in CourseStudent.query.filter_by(course_id=course.course_id).all() - ] - assert users == valid_students["students"] + # Posting to /courses/course_id/students and admins test + sel2_students_link = "/courses/" + str(valid_course_entry.course_id) - response = client.post( - sel2_students_link + "/students?uid=Bart", - json=valid_students, # already added students - ) - assert response.status_code == 400 + valid_students = [s.uid for s in valid_students_entries] response = client.post( - sel2_students_link + "/students?uid=Bart", - json=bad_students, # bad request + sel2_students_link + f"/students?uid={valid_course_entry.teacher}", + json={"students": valid_students}, ) - assert response.status_code == 400 - - sel2_admins_link = "/courses/" + str(course.course_id) + "/admins" - - course_admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert course_admins == ["Bart"] - response = client.post( - sel2_admins_link + "?uid=Bart", # authorized user - json={"admin_uid": "Rin"}, # non existent user - ) - assert response.status_code == 404 + assert response.status_code == 403 - response = client.post( - sel2_admins_link + "?uid=Bart", # authorized user - json={"admin_uid": "Rien"}, # existing user - ) - admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert admins == ["Bart", "Rien"] - def test_get_courses(self, courses_get_db, client, api_url): + def test_get_courses(self, valid_course_entries, client): """ Test all the getters for the courses endpoint """ - course = courses_get_db.query(Course).filter_by(name="Sel2").first() - sel2_students_link = "/courses/" + str(course.course_id) - - for x in range(3, 10): - response = client.get(f"/courses?name=Sel{str(x)}") - assert response.status_code == 200 - link = response.json["url"] - assert len(link) == len(f"{api_url}/courses") - response = client.get(link + "?uid=Bart") - assert response.status_code == 200 - - sel2_students = [ - {"uid": f"{api_url}/users/" + s.uid} - for s in CourseStudent.query.filter_by(course_id=course.course_id).all() - ] - - response = client.get(sel2_students_link + "/students?uid=Bart") + + response = client.get("/courses") assert response.status_code == 200 - response_json = response.json # the students ids are in the json without a key - assert response_json["data"] == sel2_students + data = response.json + for course in valid_course_entries: + assert course.name in [c["name"] for c in data["data"]] - def test_course_delete(self, courses_get_db, client): + def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" - course = courses_get_db.query(Course).filter_by(name="Sel2").first() - sel2_students_link = "/courses/" + str(course.course_id) - response = client.delete( - sel2_students_link + "/students?uid=student_sel2_0", - json={"students": ["student_sel2_0"]}, - ) - assert response.status_code == 403 - response = client.delete( - sel2_students_link + "/students?uid=Bart", - json={"students": ["student_sel2_0"]}, + "/courses/" + str(valid_course_entry.course_id), ) assert response.status_code == 200 - students = [ - s.uid - for s in CourseStudent.query.filter_by(course_id=course.course_id).all() - ] - assert students == ["student_sel2_1", "student_sel2_2"] - - response = client.delete( - sel2_students_link + "/students?uid=Bart", json={"error": ["invalid"]} - ) - assert response.status_code == 400 - - response = client.delete( - sel2_students_link + "/admins?uid=Bart", json={"admin_uid": "error"} - ) - assert response.status_code == 404 - - assert ( - sel2_students_link + "/admins?uid=Bart" - == "/courses/" + str(course.course_id) + "/admins?uid=Bart" - ) - response = client.delete( - sel2_students_link + "/admins?uid=Bart", json={"admin_ud": "Rien"} - ) - assert response.status_code == 400 - - response = client.delete( - sel2_students_link + "/admins?uid=student_sel2_0", - json={"admin_uid": "Rien"}, - ) - assert response.status_code == 403 - - admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert admins == ["Bart", "Rien"] - response = client.delete( - sel2_students_link + "/admins?uid=Bart", json={"admin_uid": "Rien"} - ) - assert response.status_code == 204 - - admins = [ - s.uid - for s in CourseAdmin.query.filter_by(course_id=course.course_id).all() - ] - assert admins == ["Bart"] - - course = Course.query.filter_by(name="Sel2").first() - assert course.teacher == "Bart" + # Is not reachable using the API + get_response = client.get(f"/courses/{valid_course_entry.course_id}") + assert get_response.status_code == 404 - def test_course_patch(self, client, session): + def test_course_patch(self, valid_course_entry, client): """ Test the patching of a course """ - course = session.query(Course).filter_by(name="AD3").first() - response = client.patch(f"/courses/{course.course_id}?uid=brinkmann", json={ - "name": "AD2" + response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ + "name": "TestTest" }) data = response.json assert response.status_code == 200 - assert data["data"]["name"] == "AD2" + assert data["data"]["name"] == "TestTest" diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index 85f3346e..6ca89968 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -2,8 +2,6 @@ This file contains the tests for the share link endpoints of the course resource. """ -from project.models.course import Course - class TestCourseShareLinks: """ Class that will respond to the /courses/course_id/students link @@ -11,17 +9,15 @@ class TestCourseShareLinks: and everyone should be able to list all students assigned to a course """ - def test_get_share_links(self, db_with_course, client): + def test_get_share_links(self, valid_course_entry, client): """Test whether the share links are accessible""" - example_course = db_with_course.query(Course).first() - response = client.get(f"courses/{example_course.course_id}/join_codes") + response = client.get(f"courses/{valid_course_entry.course_id}/join_codes") assert response.status_code == 200 - def test_post_share_links(self, db_with_course, client): + def test_post_share_links(self, valid_course_entry, client): """Test whether the share links are accessible to post to""" - example_course = db_with_course.query(Course).first() response = client.post( - f"courses/{example_course.course_id}/join_codes", + f"courses/{valid_course_entry.course_id}/join_codes", json={"for_admins": True}) assert response.status_code == 201 @@ -46,8 +42,7 @@ def test_delete_share_links_404(self, client): response = client.delete("courses/0/join_codes/0") assert response.status_code == 404 - def test_for_admins_required(self, db_with_course, client): + def test_for_admins_required(self, valid_course_entry, client): """Test whether the for_admins field is required""" - example_course = db_with_course.query(Course).first() - response = client.post(f"courses/{example_course.course_id}/join_codes", json={}) + response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index f8949ff6..24fcc2d0 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,26 +1,19 @@ """Tests for project endpoints.""" -from project.models.project import Project -def test_assignment_download(db_session, client, course_ad, course_teacher_ad, project_json): +def test_assignment_download(client, valid_project): """ Method for assignment download """ - db_session.add(course_teacher_ad) - db_session.commit() - db_session.add(course_ad) - db_session.commit() - project_json["course_id"] = course_ad.course_id - - with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = zip_file + with open("tests/resources/testzip.zip", "rb") as zip_file: + valid_project["assignment_file"] = zip_file # post the project response = client.post( "/projects", - data=project_json, + data=valid_project, content_type='multipart/form-data' ) - + assert response.status_code == 201 project_id = response.json["data"]["project_id"] response = client.get(f"/projects/{project_id}/assignments") # file downloaded succesfully @@ -33,8 +26,7 @@ def test_not_found_download(client): """ response = client.get("/projects") # get an index that doesnt exist - project_id = len(response.data)+1 - response = client.get(f"/projects/{project_id}/assignments") + response = client.get("/projects/-1/assignments") assert response.status_code == 404 @@ -51,23 +43,15 @@ def test_getting_all_projects(client): assert isinstance(response.json['data'], list) -def test_post_project(db_session, client, course_ad, course_teacher_ad, project_json): +def test_post_project(client, valid_project): """Test posting a project to the database and testing if it's present""" - db_session.add(course_teacher_ad) - db_session.commit() - - db_session.add(course_ad) - db_session.commit() - project_json["course_id"] = course_ad.course_id - # cant be done with 'with' because it autocloses then - # pylint: disable=R1732 - with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = zip_file + with open("tests/resources/testzip.zip", "rb") as zip_file: + valid_project["assignment_file"] = zip_file # post the project response = client.post( "/projects", - data=project_json, + data=valid_project, content_type='multipart/form-data' ) @@ -79,60 +63,29 @@ def test_post_project(db_session, client, course_ad, course_teacher_ad, project_ assert response.status_code == 200 -def test_remove_project(db_session, client, course_ad, course_teacher_ad, project_json): +def test_remove_project(client, valid_project_entry): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" - db_session.add(course_teacher_ad) - db_session.commit() - - db_session.add(course_ad) - db_session.commit() - - project_json["course_id"] = course_ad.course_id - - # post the project - print(project_json) - with open("testzip.zip", "rb") as zip_file: - project_json["assignment_file"] = zip_file - response = client.post("/projects", data=project_json) - - # check if the project with the id is present - project_id = response.json["data"]["project_id"] - + project_id = valid_project_entry.project_id response = client.delete(f"/projects/{project_id}") assert response.status_code == 200 # check if the project isn't present anymore and the delete indeed went through - response = client.delete(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}") assert response.status_code == 404 -def test_patch_project(db_session, client, course_ad, course_teacher_ad, project): +def test_patch_project(client, valid_project_entry): """ Test functionality of the PUT method for projects """ - db_session.add(course_teacher_ad) - db_session.commit() - - db_session.add(course_ad) - db_session.commit() - - project.course_id = course_ad.course_id - - # post the project to edit - db_session.add(project) - db_session.commit() - project_id = project.project_id + project_id = valid_project_entry.project_id - new_title = "patched title" - new_archived = not project.archived + new_title = valid_project_entry.title + "hallo" + new_archived = not valid_project_entry.archived response = client.patch(f"/projects/{project_id}", json={ "title": new_title, "archived": new_archived }) - db_session.commit() - updated_project = db_session.get(Project, {"project_id": project.project_id}) assert response.status_code == 200 - assert updated_project.title == new_title - assert updated_project.archived == new_archived diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 7736bf90..be36592f 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -12,99 +12,92 @@ class TestSubmissionsEndpoint: """Class to test the submissions API endpoint""" ### GET SUBMISSIONS ### - def test_get_submissions_wrong_user(self, client: FlaskClient, session: Session): + def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" - response = client.get("/submissions?uid=unknown") - data = response.json + response = client.get("/submissions?uid=-20") assert response.status_code == 400 - assert data["message"] == "Invalid user (uid=unknown)" - def test_get_submissions_wrong_project(self, client: FlaskClient, session: Session): + def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=-1") - data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=-1)" + assert "message" in response.json - def test_get_submissions_wrong_project_type(self, client: FlaskClient, session: Session): + def test_get_submissions_wrong_project_type(self, client: FlaskClient): """Test getting submissions for a non-existing project of the wrong type""" response = client.get("/submissions?project_id=zero") - data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=zero)" + assert "message" in response.json - def test_get_submissions_all(self, client: FlaskClient, session: Session): + def test_get_submissions_all(self, client: FlaskClient): """Test getting the submissions""" response = client.get("/submissions") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 3 + assert "message" in data + assert isinstance(data["data"], list) - def test_get_submissions_user(self, client: FlaskClient, session: Session): + def test_get_submissions_user(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific user""" - response = client.get("/submissions?uid=student01") + response = client.get(f"/submissions?uid={valid_submission_entry.uid}") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 1 + assert "message" in data - def test_get_submissions_project(self, client: FlaskClient, session: Session): + + def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.get(f"/submissions?project_id={project.project_id}") + response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 2 + assert "message" in data - def test_get_submissions_user_project(self, client: FlaskClient, session: Session): + def test_get_submissions_user_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific user and project""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.get(f"/submissions?uid=student01&project_id={project.project_id}") + response = client.get( + f"/submissions? \ + uid={valid_submission_entry.uid}&\ + project_id={valid_submission_entry.project_id}") data = response.json assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submissions" - assert len(data["data"]) == 1 + assert "message" in data ### POST SUBMISSIONS ### - def test_post_submissions_no_user(self, client: FlaskClient, session: Session, files): + def test_post_submissions_no_user(self, client: FlaskClient, valid_project_entry, files): """Test posting a submission without specifying a user""" - project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ - "project_id": project.project_id, + "project_id": valid_project_entry.project_id, "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The uid is missing" - def test_post_submissions_wrong_user(self, client: FlaskClient, session: Session, files): + def test_post_submissions_wrong_user(self, client: FlaskClient, valid_project_entry, files): """Test posting a submission for a non-existing user""" - project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ "uid": "unknown", - "project_id": project.project_id, + "project_id": valid_project_entry.project_id, "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid user (uid=unknown)" - def test_post_submissions_no_project(self, client: FlaskClient, session: Session, files): + def test_post_submissions_no_project(self, client: FlaskClient, valid_user_entry, files): """Test posting a submission without specifying a project""" response = client.post("/submissions", data={ - "uid": "student01", + "uid": valid_user_entry.uid, "files": files }) data = response.json assert response.status_code == 400 assert data["message"] == "The project_id is missing" - def test_post_submissions_wrong_project(self, client: FlaskClient, session: Session, files): + def test_post_submissions_wrong_project(self, client: FlaskClient, valid_user_entry, files): """Test posting a submission for a non-existing project""" response = client.post("/submissions", data={ - "uid": "student01", + "uid": valid_user_entry.uid, "project_id": 0, "files": files }) @@ -113,11 +106,11 @@ def test_post_submissions_wrong_project(self, client: FlaskClient, session: Sess assert data["message"] == "Invalid project (project_id=0)" def test_post_submissions_wrong_project_type( - self, client: FlaskClient, session: Session, files + self, client: FlaskClient, valid_user_entry, files ): """Test posting a submission for a non-existing project of the wrong type""" response = client.post("/submissions", data={ - "uid": "student01", + "uid": valid_user_entry.uid, "project_id": "zero", "files": files }) @@ -125,17 +118,18 @@ def test_post_submissions_wrong_project_type( assert response.status_code == 400 assert data["message"] == "Invalid project_id typing (project_id=zero)" - def test_post_submissions_no_files(self, client: FlaskClient, session: Session): + def test_post_submissions_no_files( + self, client: FlaskClient, valid_user_entry, valid_project_entry): """Test posting a submission when no files are uploaded""" - project = session.query(Project).filter_by(title="B+ Trees").first() response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id + "uid": valid_user_entry.uid, + "project_id": valid_project_entry.project_id }) data = response.json assert response.status_code == 400 assert data["message"] == "No files were uploaded" + def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): """Test posting a submission for an empty file""" project = session.query(Project).filter_by(title="B+ Trees").first() diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 96b13e3c..c20b0a29 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -7,6 +7,7 @@ - test_patch_user: Tests user update functionality and error handling for updating non-existent user. """ +from dataclasses import asdict import pytest from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine @@ -39,80 +40,78 @@ def user_db_session(): class TestUserEndpoint: """Class to test user management endpoints.""" - def test_delete_user(self, client,user_db_session): + def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user - response = client.delete("/users/del") + response = client.delete(f"/users/{valid_user_entry.uid}") assert response.status_code == 200 - assert response.json["message"] == "User deleted successfully!" - def test_delete_not_present(self, client,user_db_session): + get_response = client.get(f"/users/{valid_user_entry.uid}") + assert get_response.status_code == 404 + + def test_delete_not_present(self, client): """Test deleting a user that does not exist.""" - response = client.delete("/users/non") + response = client.delete("/users/-20") assert response.status_code == 404 - def test_wrong_form_post(self, client,user_db_session): + def test_wrong_form_post(self, client, user_invalid_field): """Test posting with a wrong form.""" - response = client.post("/users", json={ - 'uid': '12', - 'is_student': True, # wrong field name - 'is_admin': False - }) + response = client.post("/users", json=user_invalid_field) assert response.status_code == 400 - def test_wrong_datatype_post(self, client,user_db_session): - """Test posting with a wrong data type.""" - response = client.post("/users", data={ - 'uid': '12', - 'is_teacher': True, - 'is_admin': False - }) + def test_wrong_datatype_post(self, client, valid_user): + """Test posting with a wrong content type.""" + response = client.post("/users", data=valid_user) assert response.status_code == 415 - def test_get_all_users(self, client,user_db_session): + def test_get_all_users(self, client, valid_user_entries): """Test getting all users.""" response = client.get("/users") assert response.status_code == 200 # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) + for valid_user in valid_user_entries: + assert valid_user.uid in \ + [user["uid"] for user in response.json["data"]] - def test_get_one_user(self, client,user_db_session): + def test_get_one_user(self, client, valid_user_entry): """Test getting a single user.""" - response = client.get("users/u_get") + response = client.get(f"users/{valid_user_entry.uid}") assert response.status_code == 200 - assert response.json["data"] == { - 'uid': 'u_get', - 'is_teacher': True, - 'is_admin': False - } + assert "data" in response.json - def test_patch_user(self, client, user_db_session): + def test_patch_user(self, client, valid_user_entry): """Test updating a user.""" - response = client.patch("/users/pat", json={ - 'is_teacher': False, - 'is_admin': True + + new_is_teacher = not valid_user_entry.is_teacher + + response = client.patch(f"/users/{valid_user_entry.uid}", json={ + 'is_teacher': new_is_teacher, + 'is_admin': not valid_user_entry.is_admin }) assert response.status_code == 200 assert response.json["message"] == "User updated successfully!" - def test_patch_non_existent(self, client,user_db_session): + get_response = client.get(f"/users/{valid_user_entry.uid}") + assert get_response.status_code == 200 + assert get_response.json["data"]["is_teacher"] == new_is_teacher + + def test_patch_non_existent(self, client): """Test updating a non-existent user.""" - response = client.patch("/users/non", json={ + response = client.patch("/users/-20", json={ 'is_teacher': False, 'is_admin': True }) assert response.status_code == 404 - def test_patch_non_json(self, client,user_db_session): + def test_patch_non_json(self, client, valid_user_entry): """Test sending a non-JSON patch request.""" - response = client.post("/users", data={ - 'uid': '12', - 'is_teacher': True, - 'is_admin': False - }) + valid_user_form = asdict(valid_user_entry) + valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] + response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form) assert response.status_code == 415 - def test_get_users_with_query(self, client, user_db_session): + def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing response = client.get("/users?is_admin=true&is_teacher=false") @@ -120,7 +119,6 @@ def test_get_users_with_query(self, client, user_db_session): # Check that the response contains only the user that matches the query users = response.json["data"] - assert len(users) == 1 - assert users[0]["uid"] == "query_user" - assert users[0]["is_admin"] is True - assert users[0]["is_teacher"] is False + for user in users: + assert user["is_admin"] is True + assert user["is_teacher"] is False diff --git a/backend/testzip.zip b/backend/tests/resources/testzip.zip similarity index 100% rename from backend/testzip.zip rename to backend/tests/resources/testzip.zip From ba044fc9148a1855282df3dc22a304fe2396f040 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 14 Mar 2024 20:48:08 +0100 Subject: [PATCH 201/377] fix badge for workflow in readme.md (#108) * test example workflow * check if push argument works * does the value update test * Create ci-test-backend.yaml created a seperate file for the backend test * added backend badge to readme * changed yml to yaml * changed tests to test * added seperate files for linter and tests for both front and backend * gave correct names to workflows * correct names to run name * removed code dupe and pray for test run * added both badges for linter and tests from backend * file changes detected? * removed unused code parts * fix: eslint not found error * added frontend badges --- .github/workflows/ci-linter-backend.yaml | 19 +++++++ .github/workflows/ci-linter-frontend.yaml | 27 ++++++++++ .github/workflows/ci-test-backend.yaml | 24 +++++++++ .github/workflows/ci-test-frontend.yaml | 35 +++++++++++++ .github/workflows/ci-tests.yml | 62 ----------------------- backend/README.md | 2 + frontend/README.md | 4 ++ 7 files changed, 111 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/ci-linter-backend.yaml create mode 100644 .github/workflows/ci-linter-frontend.yaml create mode 100644 .github/workflows/ci-test-backend.yaml create mode 100644 .github/workflows/ci-test-frontend.yaml delete mode 100644 .github/workflows/ci-tests.yml diff --git a/.github/workflows/ci-linter-backend.yaml b/.github/workflows/ci-linter-backend.yaml new file mode 100644 index 00000000..d9bb1150 --- /dev/null +++ b/.github/workflows/ci-linter-backend.yaml @@ -0,0 +1,19 @@ +name: UGent-3-backend-linter +run-name: ${{ github.actor }} is running backend linter 🚀 +on: [pull_request] +jobs: + Backend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + cache: 'pip' + + - name: Run linting + working-directory: ./backend + run: find . -type f -name "*.py" | xargs pylint + \ No newline at end of file diff --git a/.github/workflows/ci-linter-frontend.yaml b/.github/workflows/ci-linter-frontend.yaml new file mode 100644 index 00000000..f93d6325 --- /dev/null +++ b/.github/workflows/ci-linter-frontend.yaml @@ -0,0 +1,27 @@ +name: UGent-3-frontend-linter +run-name: ${{ github.actor }} is running frontend linter 🚀 +on: [pull_request] +jobs: + Frontend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Install dependencies + working-directory: ./frontend + run: npm i eslint + + - name: Run linting + working-directory: ./frontend + run: npm run lint + \ No newline at end of file diff --git a/.github/workflows/ci-test-backend.yaml b/.github/workflows/ci-test-backend.yaml new file mode 100644 index 00000000..5335aa08 --- /dev/null +++ b/.github/workflows/ci-test-backend.yaml @@ -0,0 +1,24 @@ +name: UGent-3-backend-test +run-name: ${{ github.actor }} is running backend tests 🚀 +on: [pull_request] +jobs: + Backend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + cache: 'pip' + + - name: Install dependencies + working-directory: ./backend + run: pip3 install -r requirements.txt && pip3 install -r dev-requirements.txt + + - name: Running tests + working-directory: ./backend + run: bash ./run_tests.sh + + diff --git a/.github/workflows/ci-test-frontend.yaml b/.github/workflows/ci-test-frontend.yaml new file mode 100644 index 00000000..8d976eb4 --- /dev/null +++ b/.github/workflows/ci-test-frontend.yaml @@ -0,0 +1,35 @@ +name: UGent-3-frontend-test +run-name: ${{ github.actor }} is running frontend tests 🚀 +on: [pull_request] +jobs: + Frontend-tests: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Install dependencies + working-directory: ./frontend + run: npm ci + + - name: Build + working-directory: ./frontend + run: npm run build + + - name: Preview Web App + working-directory: ./frontend + run: npm run preview & + + - name: Running tests + working-directory: ./frontend + run: npm test + diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml deleted file mode 100644 index bd101643..00000000 --- a/.github/workflows/ci-tests.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: UGent-3 -run-name: ${{ github.actor }} is running tests 🚀 -on: [pull_request] -jobs: - Frontend-tests: - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - - - name: Cache dependencies - uses: actions/cache@v4 - with: - path: ~/.npm - key: npm-${{ hashFiles('package-lock.json') }} - restore-keys: npm- - - - name: Install dependencies - working-directory: ./frontend - run: npm ci - - - name: Build - working-directory: ./frontend - run: npm run build - - - name: Preview Web App - working-directory: ./frontend - run: npm run preview & - - - name: Running tests - working-directory: ./frontend - run: npm test - - - name: Run linting - working-directory: ./frontend - run: npm run lint - Backend-tests: - runs-on: self-hosted - steps: - - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - cache: 'pip' - - - name: Install dependencies - working-directory: ./backend - run: pip3 install -r requirements.txt && pip3 install -r dev-requirements.txt - - - name: Running tests - working-directory: ./backend - run: bash ./run_tests.sh - - - name: Run linting - working-directory: ./backend - run: find . -type f -name "*.py" | xargs pylint - - diff --git a/backend/README.md b/backend/README.md index 8beb249d..b7cd5ee4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,4 +1,6 @@ # Project pigeonhole backend +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg) ## Prerequisites **1. Clone the repo** ```sh diff --git a/frontend/README.md b/frontend/README.md index 0d6babed..be6865ac 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,3 +1,7 @@ +# Project pigeonhole backend +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) + # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. From 1af5c7ec7c680c6fdd9573ad1d352ebe3f85e263 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 14 Mar 2024 21:55:50 +0100 Subject: [PATCH 202/377] frontend developper instructions (#110) * frontend developper instructions * only 1 backtick fix --- frontend/README.md | 80 +++++++++++++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/frontend/README.md b/frontend/README.md index be6865ac..5f81d16b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,34 +1,54 @@ # Project pigeonhole backend ![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) ![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) +## Prerequisites +**1. Clone the repo** + ```sh + git clone git@github.com:SELab-2/UGent-3.git + ``` + +**2. Installing required packages** +Run the command below to install the needed dependencies. + ```sh + cd frontend + npm install + ``` +After this you can run the development or the production build with one of the following command + - Deployment + ```sh + npm run build + ``` +After running this command the build can be found in the `dist` directory. +You can choose your own preferred webserver like for example `nginx`, `serve` or something else. + + - Development + ```sh + npm run dev + ``` + +## Maintaining the codebase +### Writing tests +When writing new code it is important to maintain the right functionality so +writing tests is mandatory for this, the test library used in this codebase is [cypres e2e](https://www.cypress.io/). + +If you want to write tests we highly advise to read the cypres e2e documentation on how +to write tests, so they are kept conventional. + +For executing the tests and testing your newly added functionality +you can run: +```sh +npm run dev +``` +After the development build is running, you can run the following command on another terminal: +```sh +npm run test +``` +### Running the linter +This codebase is kept clean by the [eslint](https://eslint.org) linter. + +If you want to execute the linter on all files in the project it can simply be done +with the command: +```sh +npm run lint +``` -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json', './tsconfig.node.json'], - tsconfigRootDir: __dirname, - }, -} -``` - -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list From 7c0cc085578389aeecbad3bbc149ad274938aae7 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 14 Mar 2024 21:57:17 +0100 Subject: [PATCH 203/377] Merge backend authentication into development (#73) * start authentication * authentication start decorators * login_required should work with access token * backend authentication for most endpoints (very rough draft of functions in authentication.py) * clean_up_function * authentication cleanup * give error when access_token fails * documentation auth functions * fixed imports * actual import fix * added requests * authorize submissions * removed double checks * start testing setup backend authentication * poging testen * github tests check * user tests with authentication * auth url accessible hopefully * change authorization to be easier to deal with since it doesn't matter for tests * fixed jobCategory -> jobTitle * fix authentication * user tests zouden moeten slagen * fix authentication arguments * project tests with authentication * changed auth server id of teacher * maybe correct primary keys * second try on primary key of course relations * further test authentication * authentication on project assignment files * auth on course_join_codes and extra tests * teacher_id in function when necessary * user tests with authentication * extra testing * fixed comments * lots of testing changes * should be 1 error test now * fix tests --- backend/Dockerfile_auth_test | 9 + backend/auth_requirements.txt | 4 + .../courses/course_admin_relation.py | 6 +- .../endpoints/courses/course_details.py | 4 + .../courses/course_student_relation.py | 8 +- backend/project/endpoints/courses/courses.py | 10 +- .../courses/join_codes/course_join_code.py | 6 +- .../courses/join_codes/course_join_codes.py | 5 +- .../courses/join_codes/join_codes_utils.py | 6 +- .../projects/project_assignment_file.py | 3 + .../endpoints/projects/project_detail.py | 5 +- .../project/endpoints/projects/projects.py | 8 +- backend/project/endpoints/submissions.py | 8 +- backend/project/endpoints/users.py | 11 +- backend/project/utils/authentication.py | 395 ++++++++++++++++++ backend/project/utils/query_agent.py | 2 +- backend/requirements.txt | 3 +- backend/test_auth_server/__main__.py | 69 +++ backend/tests.yaml | 13 + backend/tests/conftest.py | 2 +- .../tests/endpoints/course/courses_test.py | 14 +- .../tests/endpoints/course/share_link_test.py | 17 +- backend/tests/endpoints/project_test.py | 23 +- backend/tests/endpoints/submissions_test.py | 186 +-------- backend/tests/endpoints/user_test.py | 73 +++- 25 files changed, 650 insertions(+), 240 deletions(-) create mode 100644 backend/Dockerfile_auth_test create mode 100644 backend/auth_requirements.txt create mode 100644 backend/project/utils/authentication.py create mode 100644 backend/test_auth_server/__main__.py diff --git a/backend/Dockerfile_auth_test b/backend/Dockerfile_auth_test new file mode 100644 index 00000000..d7541b0d --- /dev/null +++ b/backend/Dockerfile_auth_test @@ -0,0 +1,9 @@ +FROM python:3.9 +RUN mkdir /auth-app +WORKDIR /auth-app +ADD ./test_auth_server /auth-app/ +COPY auth_requirements.txt /auth-app/requirements.txt +RUN pip3 install -r requirements.txt +COPY . /auth-app +ENTRYPOINT ["python"] +CMD ["__main__.py"] \ No newline at end of file diff --git a/backend/auth_requirements.txt b/backend/auth_requirements.txt new file mode 100644 index 00000000..2a0efdf3 --- /dev/null +++ b/backend/auth_requirements.txt @@ -0,0 +1,4 @@ +flask~=3.0.2 +flask-restful +python-dotenv~=1.0.1 +psycopg2-binary \ No newline at end of file diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index cb00cb51..bd8e1fa6 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from flask import request +from flask import abort, request from flask_restful import Resource from project.models.course_relation import CourseAdmin @@ -21,6 +21,7 @@ json_message ) from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.authentication import login_required, authorize_teacher_of_course, authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") @@ -32,6 +33,7 @@ class CourseForAdmins(Resource): the /courses/course_id/admins url, only the teacher of a course can do this """ + @authorize_teacher_or_course_admin def get(self, course_id): """ This function will return all the admins of a course @@ -47,6 +49,7 @@ def get(self, course_id): filters={"course_id": course_id}, ) + @authorize_teacher_of_course def post(self, course_id): """ Api endpoint for adding new admins to a course, can only be done by the teacher @@ -72,6 +75,7 @@ def post(self, course_id): "uid" ) + @authorize_teacher_of_course def delete(self, course_id): """ Api endpoint for removing admins of a course, can only be done by the teacher diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 41b4abd5..56751c3d 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -19,6 +19,7 @@ from project.db_in import db from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model +from project.utils.authentication import login_required, authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -27,6 +28,7 @@ class CourseByCourseId(Resource): """Api endpoint for the /courses/course_id link""" + @login_required def get(self, course_id): """ This get function will return all the related projects of the course @@ -86,6 +88,7 @@ def get(self, course_id): "error": "Something went wrong while querying the database.", "url": RESPONSE_URL}, 500 + @authorize_teacher_of_course def delete(self, course_id): """ This function will delete the course with course_id @@ -97,6 +100,7 @@ def delete(self, course_id): RESPONSE_URL ) + @authorize_teacher_of_course def patch(self, course_id): """ This function will update the course with course_id diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 63b9213d..31e9c28c 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -26,6 +26,7 @@ ) from project.utils.query_agent import query_selected_from_model +from project.utils.authentication import login_required, authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") @@ -38,13 +39,14 @@ class CourseToAddStudents(Resource): and everyone should be able to list all students assigned to a course """ + @login_required def get(self, course_id): """ Get function at /courses/course_id/students to get all the users assigned to a course everyone can get this data so no need to have uid query in the link """ - abort_url = f"{API_URL}/courses/{str(course_id)}/students" + abort_url = f"{API_URL}/courses/{course_id}/students" get_course_abort_if_not_found(course_id) return query_selected_from_model( @@ -55,12 +57,13 @@ def get(self, course_id): filters={"course_id": course_id} ) + @authorize_teacher_or_course_admin def post(self, course_id): """ Allows admins of a course to assign new students by posting to: /courses/course_id/students with a list of uid in the request body under key "students" """ - abort_url = f"{API_URL}/courses/{str(course_id)}/students" + abort_url = f"{API_URL}/courses/{course_id}/students" uid = request.args.get("uid") data = request.get_json() student_uids = data.get("students") @@ -85,6 +88,7 @@ def post(self, course_id): response["data"] = data return response, 201 + @authorize_teacher_or_course_admin def delete(self, course_id): """ This function allows admins of a course to remove students by sending a delete request to diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index bafd881e..a56541e7 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -14,6 +14,7 @@ from project.models.course import Course from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.authentication import login_required, authorize_teacher load_dotenv() API_URL = getenv("API_HOST") @@ -22,6 +23,7 @@ class CourseForUser(Resource): """Api endpoint for the /courses link""" + @login_required def get(self): """ " Get function for /courses this will be the main endpoint @@ -36,15 +38,17 @@ def get(self): filters=request.args ) - def post(self): + @authorize_teacher + def post(self, teacher_id=None): """ This function will create a new course if the body of the post contains a name and uid is an admin or teacher """ - + req = request.json + req["teacher"] = teacher_id return insert_into_model( Course, - request.json, + req, RESPONSE_URL, "course_id", required_fields=["name", "teacher"] diff --git a/backend/project/endpoints/courses/join_codes/course_join_code.py b/backend/project/endpoints/courses/join_codes/course_join_code.py index df952877..97f7284d 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_code.py +++ b/backend/project/endpoints/courses/join_codes/course_join_code.py @@ -10,6 +10,7 @@ from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists +from project.utils.authentication import authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -18,7 +19,7 @@ class CourseJoinCode(Resource): """ This class will handle post and delete queries to - the /courses/course_id/join_codes url, only an admin of a course can do this + the /courses/course_id/join_codes/ url, only an admin of a course can do this """ @check_course_exists @@ -35,9 +36,10 @@ def get(self, course_id, join_code): ) @check_course_exists + @authorize_teacher_of_course def delete(self, course_id, join_code): """ - Api endpoint for adding new join codes to a course, can only be done by the teacher + Api endpoint for deleting join codes from a course, can only be done by the teacher """ return delete_by_id_from_model( diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py index 7ab142b6..103de7db 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_codes.py +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -11,6 +11,7 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.courses_utils import get_course_abort_if_not_found +from project.utils.authentication import login_required, authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -18,10 +19,11 @@ class CourseJoinCodes(Resource): """ - This class will handle post and delete queries to + This class will handle get and post queries to the /courses/course_id/join_codes url, only an admin of a course can do this """ + @login_required def get(self, course_id): """ This function will return all the join codes of a course @@ -36,6 +38,7 @@ def get(self, course_id): filters={"course_id": course_id} ) + @authorize_teacher_of_course def post(self, course_id): """ Api endpoint for adding new join codes to a course, can only be done by the teacher diff --git a/backend/project/endpoints/courses/join_codes/join_codes_utils.py b/backend/project/endpoints/courses/join_codes/join_codes_utils.py index 5078fce2..65defbb4 100644 --- a/backend/project/endpoints/courses/join_codes/join_codes_utils.py +++ b/backend/project/endpoints/courses/join_codes/join_codes_utils.py @@ -8,7 +8,7 @@ def check_course_exists(func): """ Middleware to check if the course exists before handling the request """ - def wrapper(self, course_id, join_code, *args, **kwargs): - get_course_abort_if_not_found(course_id) - return func(self, course_id, join_code, *args, **kwargs) + def wrapper(*args, **kwargs): + get_course_abort_if_not_found(kwargs["course_id"]) + return func(*args, **kwargs) return wrapper diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 61447c94..88e12ac7 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -12,6 +12,7 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model +from project.utils.authentication import authorize_project_visible API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -21,6 +22,8 @@ class ProjectAssignmentFiles(Resource): """ Class for getting the assignment files of a project """ + + @authorize_project_visible def get(self, project_id): """ Get the assignment files of a project diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index df4e99d7..691aacf0 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -12,7 +12,7 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model - +from project.utils.authentication import authorize_teacher_or_project_admin, authorize_teacher_of_project, authorize_project_visible API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") @@ -24,6 +24,7 @@ class ProjectDetail(Resource): for implementing get, delete and put methods """ + @authorize_project_visible def get(self, project_id): """ Get method for listing a specific project @@ -37,6 +38,7 @@ def get(self, project_id): project_id, RESPONSE_URL) + @authorize_teacher_or_project_admin def patch(self, project_id): """ Update method for updating a specific project @@ -51,6 +53,7 @@ def patch(self, project_id): request.json ) + @authorize_teacher_of_project def delete(self, project_id): """ Delete a project and all of its submissions in cascade diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index eabd29f9..ccbdca70 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -9,9 +9,9 @@ from flask import request, jsonify from flask_restful import Resource - from project.models.project import Project from project.utils.query_agent import query_selected_from_model, create_model_instance +from project.utils.authentication import authorize_teacher from project.endpoints.projects.endpoint_parser import parse_project_params @@ -25,7 +25,8 @@ class ProjectsEndpoint(Resource): for implementing get method """ - def get(self): + @authorize_teacher + def get(self, teacher_id=None): """ Get method for listing all available projects that are currently in the API @@ -39,7 +40,8 @@ def get(self): filters=request.args ) - def post(self): + @authorize_teacher + def post(self, teacher_id=None): """ Post functionality for project using flask_restfull parse lib diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 62d289ae..34ae2282 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -14,6 +14,7 @@ from project.utils.files import filter_files, all_files_uploaded, zip_files from project.utils.user import is_valid_user from project.utils.project import is_valid_project +from project.utils.authentication import authorize_submission_request, authorize_submissions_request, authorize_grader, authorize_student_submission, authorize_submission_author load_dotenv() API_HOST = getenv("API_HOST") @@ -24,6 +25,7 @@ class SubmissionsEndpoint(Resource): """API endpoint for the submissions""" + @authorize_submissions_request def get(self) -> dict[str, any]: """Get all the submissions from a user for a project @@ -66,6 +68,7 @@ def get(self) -> dict[str, any]: data["message"] = "An error occurred while fetching the submissions" return data, 500 + @authorize_student_submission def post(self) -> dict[str, any]: """Post a new submission to a project @@ -142,6 +145,7 @@ def post(self) -> dict[str, any]: class SubmissionEndpoint(Resource): """API endpoint for the submission""" + @authorize_submission_request def get(self, submission_id: int) -> dict[str, any]: """Get the submission given an submission ID @@ -180,6 +184,7 @@ def get(self, submission_id: int) -> dict[str, any]: f"An error occurred while fetching the submission (submission_id={submission_id})" return data, 500 + @authorize_grader def patch(self, submission_id:int) -> dict[str, any]: """Update some fields of a submission given a submission ID @@ -232,6 +237,7 @@ def patch(self, submission_id:int) -> dict[str, any]: f"An error occurred while patching submission (submission_id={submission_id})" return data, 500 + @authorize_submission_author def delete(self, submission_id: int) -> dict[str, any]: """Delete a submission given a submission ID @@ -270,4 +276,4 @@ def delete(self, submission_id: int) -> dict[str, any]: submissions_bp.add_url_rule( "/submissions/", view_func=SubmissionEndpoint.as_view("submission") -) +) \ No newline at end of file diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index cfaf63db..1e46994e 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -6,8 +6,9 @@ from flask_restful import Resource, Api from sqlalchemy.exc import SQLAlchemyError -from project.db_in import db +from project import db from project.models.user import User as userModel +from project.utils.authentication import login_required, authorize_user, not_allowed users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -15,9 +16,11 @@ load_dotenv() API_URL = getenv("API_HOST") + class Users(Resource): """Api endpoint for the /users route""" + @login_required def get(self): """ This function will respond to get requests made to /users. @@ -43,7 +46,9 @@ def get(self): return {"message": "An error occurred while fetching the users", "url": f"{API_URL}/users"}, 500 + @not_allowed def post(self): + # TODO make it so this just creates a user for yourself """ This function will respond to post requests made to /users. It should create a new user and return a success message. @@ -80,10 +85,10 @@ def post(self): "url": f"{API_URL}/users"}, 500 - class User(Resource): """Api endpoint for the /users/{user_id} route""" + @login_required def get(self, user_id): """ This function will respond to GET requests made to /users/. @@ -100,6 +105,7 @@ def get(self, user_id): return {"message": "An error occurred while fetching the user", "url": f"{API_URL}/users"}, 500 + @not_allowed def patch(self, user_id): """ Update the user's information. @@ -131,6 +137,7 @@ def patch(self, user_id): "url": f"{API_URL}/users"}, 500 + @authorize_user def delete(self, user_id): """ This function will respond to DELETE requests made to /users/. diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py new file mode 100644 index 00000000..6501da9e --- /dev/null +++ b/backend/project/utils/authentication.py @@ -0,0 +1,395 @@ +""" +This module contains the functions to authenticate API calls. +""" +from os import getenv + +from dotenv import load_dotenv + +from functools import wraps +from flask import abort, request, make_response +import requests + +from project import db + +from project.models.user import User +from project.models.course import Course +from project.models.project import Project +from project.models.submission import Submission +from project.models.course_relation import CourseAdmin, CourseStudent +from sqlalchemy.exc import SQLAlchemyError + +load_dotenv() +API_URL = getenv("API_HOST") +AUTHENTICATION_URL = getenv("AUTHENTICATION_URL") + + +def abort_with_message(code: int, message: str): + """Helper function to abort with a given status code and message""" + abort(make_response({"message": message}, code)) + + +def not_allowed(f): + """Decorator function to immediately abort the current request and return 403: Forbidden""" + @wraps(f) + def wrap(*args, **kwargs): + abort_with_message(403, "Forbidden action") + return wrap + + +def return_authenticated_user_id(): + """This function will authenticate the request and check whether the authenticated user + is already in the database, if not, they will be added + """ + authentication = request.headers.get("Authorization") + if not authentication: + abort_with_message(401, "No authorization given, you need an access token to use this API") + + auth_header = {"Authorization": authentication} + response = requests.get(AUTHENTICATION_URL, headers=auth_header) + if not response: + abort_with_message(401, "An error occured while trying to authenticate your access token") + if response.status_code != 200: + abort_with_message(401, response.json()["error"]) + + user_info = response.json() + auth_user_id = user_info["id"] + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return abort_with_message(500, "An unexpected database error occured while fetching the user") + + if user: + return auth_user_id + is_teacher = False + if user_info["jobTitle"] != None: + is_teacher = True + + # add user if not yet in database + try: + new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) + db.session.add(new_user) + db.session.commit() + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return abort_with_message(500, "An unexpected database error occured while creating the user during authentication") + return auth_user_id + + +def is_teacher(auth_user_id): + """This function checks whether the user with auth_user_id is a teacher""" + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + if not user: # should realistically never happen + abort(500, "A database error occured") + if user.is_teacher: + return True + return False + + +def is_teacher_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is the teacher of the course: course_id""" + try: + course = db.session.get(Course, course_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + + if not course: + abort_with_message(404, f"Could not find course with id: {course_id}") + + if auth_user_id == course.teacher: + return True + + +def is_admin_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is an admin of the course: course_id""" + try: + course_admin = db.session.get(CourseAdmin, (course_id, auth_user_id)) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + + if course_admin: + return True + + return False + + +def is_student_of_course(auth_user_id, course_id): + """This function checks whether the user with auth_user_id is a student of the course: course_id""" + try: + course_student = db.session.get(CourseStudent, (course_id, auth_user_id)) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500 + if course_student: + return True + return False + + +def get_course_of_project(project_id): + """This function returns the course_id of the course associated with the project: project_id""" + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + return {"message": "An error occurred while fetching the project", + "url": f"{API_URL}/users"}, 500 + + if not project: + abort_with_message(404, f"Could not find project with id: {project_id}") + + return project.course_id + + +def project_visible(project_id): + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the project") + if not project: + abort_with_message(404, "Project with given id not found") + return project.visible_for_students + + +def get_course_of_submission(submission_id): + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + return get_course_of_project(submission.project_id) + + +def login_required(f): + """ + This function will check if the person sending a request to the API is logged in + and additionally create their user entry in the database if necessary + """ + @wraps(f) + def wrap(*args, **kwargs): + return_authenticated_user_id() + return f(*args, **kwargs) + return wrap + + +def authorize_teacher(f): + """ + This function will check if the person sending a request to the API is logged in and a teacher. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_teacher(auth_user_id): + kwargs["teacher_id"] = auth_user_id + return f(*args, **kwargs) + abort_with_message(403, "You are not authorized to perfom this action, only teachers are authorized") + return wrap + + +def authorize_teacher_of_course(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course in the request. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_teacher_of_course(auth_user_id, kwargs["course_id"]): + return f(*args, **kwargs) + + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_teacher_or_course_admin(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course in the request or an admin of this course. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + course_id = kwargs["course_id"] + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, only teachers and course admins are authorized") + return wrap + + +def authorize_user(f): + """ + This function will check if the person sending a request to the API is logged in, + and the same user that the request is about. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + user_id = kwargs["user_id"] + if auth_user_id == user_id: + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, you are not this user") + return wrap + + +def authorize_teacher_of_project(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course which the project in the request belongs to. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + + if is_teacher_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + abort_with_message(403, "You are not authorized to perfom this action, you are not the teacher of this project") + return wrap + + +def authorize_teacher_or_project_admin(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher or an admin of the course which the project in the request belongs to. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, """You are not authorized to perfom this action, + you are not the teacher or an admin of this project""") + return wrap + + +def authorize_project_visible(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher of the course which the project in the request belongs to. + Or if the person is a student of this course, it will return the project if it is visible for students. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + +def authorize_submissions_request(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = request.args["project_id"] + course_id = get_course_of_project(project_id) + + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.args.get("uid"): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_student_submission(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = request.form["project_id"] + course_id = get_course_of_project(project_id) + if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.form.get("uid"): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_submission_author(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + submission_id = kwargs["submission_id"] + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + if submission.uid == auth_user_id: + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_grader(f): + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + course_id = get_course_of_submission(kwargs["submission_id"]) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap + + +def authorize_submission_request(f): + @wraps(f) + def wrap(*args, **kwargs): + # submission_author / grader mag hier aan + auth_user_id = return_authenticated_user_id() + submission_id = kwargs["submission_id"] + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + # every exception should result in a rollback + db.session.rollback() + abort_with_message(500, "An error occurred while fetching the submission") + if not submission: + abort_with_message(404, f"Submission with id: {submission_id} not found") + if submission.uid == auth_user_id: + return f(*args, **kwargs) + course_id = get_course_of_project(submission.project_id) + if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + return f(*args, **kwargs) + abort_with_message(403, "You're not authorized to perform this action") + return wrap diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 745006a1..d9f7d9cd 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -232,4 +232,4 @@ def patch_by_id_from_model(model: DeclarativeMeta, "url": urljoin(f"{base_url}/", str(column_id))}), 200 except SQLAlchemyError: return {"error": "Something went wrong while updating the database.", - "url": base_url}, 500 + "url": base_url}, 500 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 0076b0f8..9e9dc90a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,4 +4,5 @@ flask-sqlalchemy python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 -SQLAlchemy~=2.0.27 \ No newline at end of file +SQLAlchemy~=2.0.27 +requests~=2.25.1 \ No newline at end of file diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py new file mode 100644 index 00000000..2544968d --- /dev/null +++ b/backend/test_auth_server/__main__.py @@ -0,0 +1,69 @@ +"""Main entry point for the application.""" + +from dotenv import load_dotenv +from flask import Flask + +"""Index api point""" +from flask import Blueprint, request +from flask_restful import Resource, Api + +index_bp = Blueprint("index", __name__) +index_endpoint = Api(index_bp) + +token_dict = { + "teacher1":{ + "id":"Gunnar", + "jobTitle":"teacher" + }, + "teacher2":{ + "id":"Bart", + "jobTitle":"teacher" + }, + "student1":{ + "id":"w_student", + "jobTitle":None + }, + "student01":{ + "id":"student01", + "jobTitle":None + }, + "course_admin1":{ + "id":"Rien", + "jobTitle":None + }, + "del_user":{ + "id":"del", + "jobTitle":None + }, + "ad3_teacher":{ + "id":"brinkmann", + "jobTitle0":"teacher" + }, + "student02":{ + "id":"student02", + "jobTitle":None + }, +} + +class Index(Resource): + """Api endpoint for the / route""" + + def get(self): + auth = request.headers.get("Authorization") + if not auth: + return {"error":"Please give authorization"}, 401 + if auth in token_dict.keys(): + return token_dict[auth], 200 + return {"error":"Wrong address"}, 401 + + +index_bp.add_url_rule("/", view_func=Index.as_view("index")) + +if __name__ == "__main__": + load_dotenv() + + app = Flask(__name__) + app.register_blueprint(index_bp) + + app.run(debug=True, host='0.0.0.0') + diff --git a/backend/tests.yaml b/backend/tests.yaml index fd6d7a16..d1a41efb 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -15,6 +15,16 @@ services: start_period: 5s volumes: - ./db_construct.sql:/docker-entrypoint-initdb.d/init.sql + auth-server: + build: + context: . + dockerfile: ./Dockerfile_auth_test + environment: + API_HOST: http://auth-server + volumes: + - .:/auth-app + command: ["test_auth_server"] + test-runner: build: @@ -23,12 +33,15 @@ services: depends_on: postgres: condition: service_healthy + auth-server: + condition: service_started environment: POSTGRES_HOST: postgres # Use the service name defined in Docker Compose POSTGRES_USER: test_user POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here + AUTHENTICATION_URL: http://auth-server:5000 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments volumes: - .:/app diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aebe7ce9..7be87a8c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -153,4 +153,4 @@ def session(): # Truncate all tables for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) - session.commit() + session.commit() \ No newline at end of file diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index c9b64e15..3d5e199f 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -8,14 +8,14 @@ def test_post_courses(self, client, valid_course): Test posting a course to the /courses endpoint """ - response = client.post("/courses", json=valid_course) + response = client.post("/courses", json=valid_course, headers={"Authorization":"teacher2"}) assert response.status_code == 201 data = response.json assert data["data"]["name"] == "Sel" assert data["data"]["teacher"] == valid_course["teacher"] # Is reachable using the API - get_response = client.get(f"/courses/{data['data']['course_id']}") + get_response = client.get(f"/courses/{data['data']['course_id']}", headers={"Authorization":"teacher2"}) assert get_response.status_code == 200 @@ -32,7 +32,7 @@ def test_post_courses_course_id_students_and_admins( response = client.post( sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - json={"students": valid_students}, + json={"students": valid_students}, headers={"Authorization":"teacher2"} ) assert response.status_code == 403 @@ -43,7 +43,7 @@ def test_get_courses(self, valid_course_entries, client): Test all the getters for the courses endpoint """ - response = client.get("/courses") + response = client.get("/courses", headers={"Authorization":"teacher1"}) assert response.status_code == 200 data = response.json for course in valid_course_entries: @@ -53,12 +53,12 @@ def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" response = client.delete( - "/courses/" + str(valid_course_entry.course_id), + "/courses/" + str(valid_course_entry.course_id), headers={"Authorization":"teacher2"} ) assert response.status_code == 200 # Is not reachable using the API - get_response = client.get(f"/courses/{valid_course_entry.course_id}") + get_response = client.get(f"/courses/{valid_course_entry.course_id}", headers={"Authorization":"teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): @@ -67,7 +67,7 @@ def test_course_patch(self, valid_course_entry, client): """ response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ "name": "TestTest" - }) + }, headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert data["data"]["name"] == "TestTest" diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index 6ca89968..f199ab06 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -11,38 +11,33 @@ class TestCourseShareLinks: def test_get_share_links(self, valid_course_entry, client): """Test whether the share links are accessible""" - response = client.get(f"courses/{valid_course_entry.course_id}/join_codes") + response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_post_share_links(self, valid_course_entry, client): """Test whether the share links are accessible to post to""" response = client.post( f"courses/{valid_course_entry.course_id}/join_codes", - json={"for_admins": True}) + json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 201 def test_delete_share_links(self, share_code_admin, client): """Test whether the share links are accessible to delete""" response = client.delete( - f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}") + f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_get_share_links_404(self, client): """Test whether the share links are accessible""" - response = client.get("courses/0/join_codes") + response = client.get("courses/0/join_codes", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_post_share_links_404(self, client): """Test whether the share links are accessible to post to""" - response = client.post("courses/0/join_codes", json={"for_admins": True}) - assert response.status_code == 404 - - def test_delete_share_links_404(self, client): - """Test whether the share links are accessible to delete""" - response = client.delete("courses/0/join_codes/0") + response = client.post("courses/0/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_for_admins_required(self, valid_course_entry, client): """Test whether the for_admins field is required""" - response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}) + response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}, headers={"Authorization":"teacher2"}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 24fcc2d0..fb9be82c 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -11,11 +11,12 @@ def test_assignment_download(client, valid_project): response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data' + content_type='multipart/form-data', + headers={"Authorization":"teacher2"} ) assert response.status_code == 201 project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignments") + response = client.get(f"/projects/{project_id}/assignments", headers={"Authorization":"teacher2"}) # file downloaded succesfully assert response.status_code == 200 @@ -26,19 +27,19 @@ def test_not_found_download(client): """ response = client.get("/projects") # get an index that doesnt exist - response = client.get("/projects/-1/assignments") + response = client.get("/projects/-1/assignments", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_projects_home(client): """Test home project endpoint.""" - response = client.get("/projects") + response = client.get("/projects", headers={"Authorization":"teacher1"}) assert response.status_code == 200 def test_getting_all_projects(client): """Test getting all projects""" - response = client.get("/projects") + response = client.get("/projects", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert isinstance(response.json['data'], list) @@ -52,14 +53,14 @@ def test_post_project(client, valid_project): response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data' + content_type='multipart/form-data', headers={"Authorization":"teacher2"} ) assert response.status_code == 201 # check if the project with the id is present project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 @@ -67,16 +68,16 @@ def test_remove_project(client, valid_project_entry): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" project_id = valid_project_entry.project_id - response = client.delete(f"/projects/{project_id}") + response = client.delete(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 200 # check if the project isn't present anymore and the delete indeed went through - response = client.get(f"/projects/{project_id}") + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) assert response.status_code == 404 def test_patch_project(client, valid_project_entry): """ - Test functionality of the PUT method for projects + Test functionality of the PATCH method for projects """ project_id = valid_project_entry.project_id @@ -86,6 +87,6 @@ def test_patch_project(client, valid_project_entry): response = client.patch(f"/projects/{project_id}", json={ "title": new_title, "archived": new_archived - }) + }, headers={"Authorization":"teacher2"}) assert response.status_code == 200 diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index be36592f..60fd971a 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -14,186 +14,36 @@ class TestSubmissionsEndpoint: ### GET SUBMISSIONS ### def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" - response = client.get("/submissions?uid=-20") + response = client.get("/submissions?uid=-20", headers={"Authorization":"teacher1"}) assert response.status_code == 400 def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" - response = client.get("/submissions?project_id=-1") - assert response.status_code == 400 + response = client.get("/submissions?project_id=-1", headers={"Authorization":"teacher1"}) + assert response.status_code == 404 # can't find course of project in authorization assert "message" in response.json def test_get_submissions_wrong_project_type(self, client: FlaskClient): """Test getting submissions for a non-existing project of the wrong type""" - response = client.get("/submissions?project_id=zero") + response = client.get("/submissions?project_id=zero", headers={"Authorization":"teacher1"}) assert response.status_code == 400 assert "message" in response.json - def test_get_submissions_all(self, client: FlaskClient): - """Test getting the submissions""" - response = client.get("/submissions") - data = response.json - assert response.status_code == 200 - assert "message" in data - assert isinstance(data["data"], list) - - def test_get_submissions_user(self, client: FlaskClient, valid_submission_entry): - """Test getting the submissions given a specific user""" - response = client.get(f"/submissions?uid={valid_submission_entry.uid}") - data = response.json - assert response.status_code == 200 - assert "message" in data - - def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" - response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}") - data = response.json - assert response.status_code == 200 - assert "message" in data - - def test_get_submissions_user_project(self, client: FlaskClient, valid_submission_entry): - """Test getting the submissions given a specific user and project""" - response = client.get( - f"/submissions? \ - uid={valid_submission_entry.uid}&\ - project_id={valid_submission_entry.project_id}") + response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert "message" in data - ### POST SUBMISSIONS ### - def test_post_submissions_no_user(self, client: FlaskClient, valid_project_entry, files): - """Test posting a submission without specifying a user""" - response = client.post("/submissions", data={ - "project_id": valid_project_entry.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "The uid is missing" - - def test_post_submissions_wrong_user(self, client: FlaskClient, valid_project_entry, files): - """Test posting a submission for a non-existing user""" - response = client.post("/submissions", data={ - "uid": "unknown", - "project_id": valid_project_entry.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid user (uid=unknown)" - - def test_post_submissions_no_project(self, client: FlaskClient, valid_user_entry, files): - """Test posting a submission without specifying a project""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "The project_id is missing" - - def test_post_submissions_wrong_project(self, client: FlaskClient, valid_user_entry, files): - """Test posting a submission for a non-existing project""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": 0, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid project (project_id=0)" - - def test_post_submissions_wrong_project_type( - self, client: FlaskClient, valid_user_entry, files - ): - """Test posting a submission for a non-existing project of the wrong type""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": "zero", - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid project_id typing (project_id=zero)" - - def test_post_submissions_no_files( - self, client: FlaskClient, valid_user_entry, valid_project_entry): - """Test posting a submission when no files are uploaded""" - response = client.post("/submissions", data={ - "uid": valid_user_entry.uid, - "project_id": valid_project_entry.project_id - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - - def test_post_submissions_empty_file(self, client: FlaskClient, session: Session, file_empty): - """Test posting a submission for an empty file""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": file_empty - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - def test_post_submissions_file_with_no_name( - self, client: FlaskClient, session: Session, file_no_name - ): - """Test posting a submission for a file without a name""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": file_no_name - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "No files were uploaded" - - def test_post_submissions_missing_required_files( - self, client: FlaskClient, session: Session, files - ): - """Test posting a submissions for a file with a wrong name""" - project = session.query(Project).filter_by(title="B+ Trees").first() - response = client.post("/submissions", data={ - "uid": "student01", - "project_id": project.project_id, - "files": files - }) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Not all required files were uploaded" - - def test_post_submissions_correct( - self, client: FlaskClient, session: Session, files - ): - """Test posting a submission""" - project = session.query(Project).filter_by(title="Predicaten").first() - response = client.post("/submissions", data={ - "uid": "student02", - "project_id": project.project_id, - "files": files - }) - data = response.json - assert response.status_code == 201 - assert data["message"] == "Successfully fetched the submissions" - assert data["url"] == f"{API_HOST}/submissions/{data['data']['id']}" - assert data["data"]["user"] == f"{API_HOST}/users/student02" - assert data["data"]["project"] == f"{API_HOST}/projects/{project.project_id}" ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - response = client.get("/submissions/0") + response = client.get("/submissions/0", headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_get_submission_correct(self, client: FlaskClient, session: Session): """Test getting a submission""" @@ -201,7 +51,7 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.get(f"/submissions/{submission.submission_id}") + response = client.get(f"/submissions/{submission.submission_id}", headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" @@ -218,10 +68,10 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - response = client.patch("/submissions/0", data={"grading": 20}) + response = client.patch("/submissions/0", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): """Test patching a submission with a wrong grading""" @@ -229,7 +79,7 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}) + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -240,18 +90,18 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}) + response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" - def test_patch_submission_correct(self, client: FlaskClient, session: Session): + def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Session): """Test patching a submission""" project = session.query(Project).filter_by(title="B+ Trees").first() submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}) + response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" @@ -265,14 +115,16 @@ def test_patch_submission_correct(self, client: FlaskClient, session: Session): "path": "/submissions/2", "status": False } + + # TODO test course admin (allowed) and student (not allowed) patch ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): """Test deleting a submission for a non-existing submission id""" - response = client.delete("submissions/0") + response = client.delete("submissions/0", headers={"Authorization":"student01"}) data = response.json assert response.status_code == 404 - assert data["message"] == "Submission (submission_id=0) not found" + assert data["message"] == "Submission with id: 0 not found" def test_delete_submission_correct(self, client: FlaskClient, session: Session): """Test deleting a submission""" @@ -280,7 +132,7 @@ def test_delete_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.delete(f"submissions/{submission.submission_id}") + response = client.delete(f"submissions/{submission.submission_id}", headers={"Authorization":"student01"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c20b0a29..25b28d62 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -37,48 +37,82 @@ def user_db_session(): for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) session.commit() + class TestUserEndpoint: """Class to test user management endpoints.""" def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user - response = client.delete(f"/users/{valid_user_entry.uid}") + response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"student1"}) assert response.status_code == 200 - get_response = client.get(f"/users/{valid_user_entry.uid}") + # If student 1 sends this request, he would get added again + get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + assert get_response.status_code == 404 + + def test_delete_user_not_yourself(self, client, valid_user_entry): + """Test deleting a user that is not the user the authentication belongs to.""" + # Delete the user + response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + assert response.status_code == 403 + + # If student 1 sends this request, he would get added again + get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + + assert get_response.status_code == 200 def test_delete_not_present(self, client): """Test deleting a user that does not exist.""" - response = client.delete("/users/-20") - assert response.status_code == 404 + response = client.delete("/users/-20", headers={"Authorization":"student1"}) + assert response.status_code == 403 # User does not exist, so you are not the user - def test_wrong_form_post(self, client, user_invalid_field): - """Test posting with a wrong form.""" + def test_post_no_authentication(self, client, user_invalid_field): + """Test posting without authentication.""" response = client.post("/users", json=user_invalid_field) - assert response.status_code == 400 + assert response.status_code == 403 # POST to /users is not allowed - def test_wrong_datatype_post(self, client, valid_user): - """Test posting with a wrong content type.""" - response = client.post("/users", data=valid_user) - assert response.status_code == 415 + def test_post_authenticated(self, client, valid_user): + """Test posting with wrong authentication.""" + response = client.post("/users", data=valid_user, headers={"Authorization":"teacher1"}) + assert response.status_code == 403 # POST to /users is not allowed def test_get_all_users(self, client, valid_user_entries): """Test getting all users.""" - response = client.get("/users") + response = client.get("/users", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) for valid_user in valid_user_entries: assert valid_user.uid in \ [user["uid"] for user in response.json["data"]] + + def test_get_all_users_no_authentication(self, client): + """Test getting all users without authentication.""" + response = client.get("/users") + assert response.status_code == 401 + + def test_get_all_users_wrong_authentication(self, client): + """Test getting all users with wrong authentication.""" + response = client.get("/users", headers={"Authorization":"wrong"}) + assert response.status_code == 401 def test_get_one_user(self, client, valid_user_entry): """Test getting a single user.""" - response = client.get(f"users/{valid_user_entry.uid}") + response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert "data" in response.json + + def test_get_one_user_no_authentication(self, client, valid_user_entry): + """Test getting a single user without authentication.""" + response = client.get(f"users/{valid_user_entry.uid}") + assert response.status_code == 401 + + def test_get_one_user_wrong_authentication(self, client, valid_user_entry): + """Test getting a single user with wrong authentication.""" + response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"wrong"}) + assert response.status_code == 401 def test_patch_user(self, client, valid_user_entry): """Test updating a user.""" @@ -89,12 +123,7 @@ def test_patch_user(self, client, valid_user_entry): 'is_teacher': new_is_teacher, 'is_admin': not valid_user_entry.is_admin }) - assert response.status_code == 200 - assert response.json["message"] == "User updated successfully!" - - get_response = client.get(f"/users/{valid_user_entry.uid}") - assert get_response.status_code == 200 - assert get_response.json["data"]["is_teacher"] == new_is_teacher + assert response.status_code == 403 # Patching a user is never necessary and thus not allowed def test_patch_non_existent(self, client): """Test updating a non-existent user.""" @@ -102,19 +131,19 @@ def test_patch_non_existent(self, client): 'is_teacher': False, 'is_admin': True }) - assert response.status_code == 404 + assert response.status_code == 403 # Patching is not allowed def test_patch_non_json(self, client, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form) - assert response.status_code == 415 + assert response.status_code == 403 # Patching is not allowed def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false") + response = client.get("/users?is_admin=true&is_teacher=false", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query From 4e34eef1521c034d566aed75f40be89718f0e1b5 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Fri, 15 Mar 2024 14:38:21 +0100 Subject: [PATCH 204/377] Fix #114 (#115) * Fix #114 * test 1 * Fix #114 try 2 * fixed misnamed runner * removed trailing new line --- .github/workflows/ci-linter-backend.yaml | 5 ++++- .github/workflows/ci-linter-frontend.yaml | 5 ++++- .github/workflows/ci-test-backend.yaml | 5 ++++- .github/workflows/ci-test-frontend.yaml | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-linter-backend.yaml b/.github/workflows/ci-linter-backend.yaml index d9bb1150..401e96fb 100644 --- a/.github/workflows/ci-linter-backend.yaml +++ b/.github/workflows/ci-linter-backend.yaml @@ -1,6 +1,9 @@ name: UGent-3-backend-linter run-name: ${{ github.actor }} is running backend linter 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'backend/**' jobs: Backend-tests: runs-on: self-hosted diff --git a/.github/workflows/ci-linter-frontend.yaml b/.github/workflows/ci-linter-frontend.yaml index f93d6325..8efab247 100644 --- a/.github/workflows/ci-linter-frontend.yaml +++ b/.github/workflows/ci-linter-frontend.yaml @@ -1,6 +1,9 @@ name: UGent-3-frontend-linter run-name: ${{ github.actor }} is running frontend linter 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'frontend/**' jobs: Frontend-tests: runs-on: self-hosted diff --git a/.github/workflows/ci-test-backend.yaml b/.github/workflows/ci-test-backend.yaml index 5335aa08..0b2faf75 100644 --- a/.github/workflows/ci-test-backend.yaml +++ b/.github/workflows/ci-test-backend.yaml @@ -1,6 +1,9 @@ name: UGent-3-backend-test run-name: ${{ github.actor }} is running backend tests 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'backend/**' jobs: Backend-tests: runs-on: self-hosted diff --git a/.github/workflows/ci-test-frontend.yaml b/.github/workflows/ci-test-frontend.yaml index 8d976eb4..210ec6d0 100644 --- a/.github/workflows/ci-test-frontend.yaml +++ b/.github/workflows/ci-test-frontend.yaml @@ -1,6 +1,9 @@ name: UGent-3-frontend-test run-name: ${{ github.actor }} is running frontend tests 🚀 -on: [pull_request] +on: + pull_request: + paths: + - 'frontend/**' jobs: Frontend-tests: runs-on: self-hosted From 988da504688218c256a1014bf43f36a535aa893b Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:13:43 +0100 Subject: [PATCH 205/377] Merge cleanup of authentication and tests into development (#111) * start authentication * authentication start decorators * login_required should work with access token * backend authentication for most endpoints (very rough draft of functions in authentication.py) * clean_up_function * authentication cleanup * give error when access_token fails * documentation auth functions * fixed imports * actual import fix * added requests * authorize submissions * removed double checks * start testing setup backend authentication * poging testen * github tests check * user tests with authentication * auth url accessible hopefully * change authorization to be easier to deal with since it doesn't matter for tests * fixed jobCategory -> jobTitle * fix authentication * user tests zouden moeten slagen * fix authentication arguments * project tests with authentication * changed auth server id of teacher * maybe correct primary keys * second try on primary key of course relations * further test authentication * authentication on project assignment files * auth on course_join_codes and extra tests * teacher_id in function when necessary * user tests with authentication * extra testing * fixed comments * lots of testing changes * should be 1 error test now * fix tests * small linter fix * should fix the linter * actually fixed this time * linter all return statements * allow admins to patch users * should pass all tests * mergeable * grouped imports * made requested changes * requested changes * line too long * unused imports * added models folder to utils --- .../courses/course_admin_relation.py | 7 +- .../endpoints/courses/course_details.py | 2 +- .../courses/course_student_relation.py | 2 +- backend/project/endpoints/courses/courses.py | 2 +- .../endpoints/courses/courses_utils.py | 2 +- .../courses/join_codes/course_join_code.py | 4 +- .../courses/join_codes/course_join_codes.py | 4 +- .../courses/join_codes/join_codes_utils.py | 14 - .../endpoints/projects/project_detail.py | 3 +- backend/project/endpoints/submissions.py | 6 +- backend/project/endpoints/users.py | 16 +- backend/project/utils/authentication.py | 282 +++++++----------- backend/project/utils/models/course_utils.py | 67 +++++ backend/project/utils/models/project_utils.py | 41 +++ .../project/utils/models/submission_utils.py | 33 ++ backend/project/utils/models/user_utils.py | 36 +++ backend/project/utils/query_agent.py | 2 +- backend/test_auth_server/__main__.py | 23 +- backend/tests/conftest.py | 2 +- backend/tests/endpoints/conftest.py | 31 +- .../tests/endpoints/course/courses_test.py | 6 +- .../tests/endpoints/course/share_link_test.py | 22 +- backend/tests/endpoints/project_test.py | 7 +- backend/tests/endpoints/submissions_test.py | 29 +- backend/tests/endpoints/user_test.py | 65 ++-- 25 files changed, 434 insertions(+), 274 deletions(-) delete mode 100644 backend/project/endpoints/courses/join_codes/join_codes_utils.py create mode 100644 backend/project/utils/models/course_utils.py create mode 100644 backend/project/utils/models/project_utils.py create mode 100644 backend/project/utils/models/submission_utils.py create mode 100644 backend/project/utils/models/user_utils.py diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index bd8e1fa6..43c1cf1e 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -7,7 +7,7 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from flask import abort, request +from flask import request from flask_restful import Resource from project.models.course_relation import CourseAdmin @@ -21,11 +21,12 @@ json_message ) from project.utils.query_agent import query_selected_from_model, insert_into_model -from project.utils.authentication import login_required, authorize_teacher_of_course, authorize_teacher_or_course_admin +from project.utils.authentication import authorize_teacher_of_course, \ + authorize_teacher_or_course_admin load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseForAdmins(Resource): """ diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 56751c3d..cfbfe5ca 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -23,7 +23,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseByCourseId(Resource): """Api endpoint for the /courses/course_id link""" diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 31e9c28c..369fc4c2 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -30,7 +30,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseToAddStudents(Resource): """ diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index a56541e7..7b494b04 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -18,7 +18,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") class CourseForUser(Resource): """Api endpoint for the /courses link""" diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index 0489e775..cb36c6a4 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -17,7 +17,7 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(API_URL + "/", "courses") +RESPONSE_URL = urljoin(f"{API_URL}/", "courses") def execute_query_abort_if_db_error(query, url, query_all=False): """ diff --git a/backend/project/endpoints/courses/join_codes/course_join_code.py b/backend/project/endpoints/courses/join_codes/course_join_code.py index 97f7284d..c5cbfb17 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_code.py +++ b/backend/project/endpoints/courses/join_codes/course_join_code.py @@ -9,7 +9,6 @@ from flask_restful import Resource from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model from project.models.course_share_code import CourseShareCode -from project.endpoints.courses.join_codes.join_codes_utils import check_course_exists from project.utils.authentication import authorize_teacher_of_course load_dotenv() @@ -22,7 +21,7 @@ class CourseJoinCode(Resource): the /courses/course_id/join_codes/ url, only an admin of a course can do this """ - @check_course_exists + @authorize_teacher_of_course def get(self, course_id, join_code): """ This function will return all the join codes of a course @@ -35,7 +34,6 @@ def get(self, course_id, join_code): urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes") ) - @check_course_exists @authorize_teacher_of_course def delete(self, course_id, join_code): """ diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py index 103de7db..a2401783 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_codes.py +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -11,7 +11,7 @@ from project.utils.query_agent import query_selected_from_model, insert_into_model from project.models.course_share_code import CourseShareCode from project.endpoints.courses.courses_utils import get_course_abort_if_not_found -from project.utils.authentication import login_required, authorize_teacher_of_course +from project.utils.authentication import authorize_teacher_of_course load_dotenv() API_URL = getenv("API_HOST") @@ -23,7 +23,7 @@ class CourseJoinCodes(Resource): the /courses/course_id/join_codes url, only an admin of a course can do this """ - @login_required + @authorize_teacher_of_course def get(self, course_id): """ This function will return all the join codes of a course diff --git a/backend/project/endpoints/courses/join_codes/join_codes_utils.py b/backend/project/endpoints/courses/join_codes/join_codes_utils.py deleted file mode 100644 index 65defbb4..00000000 --- a/backend/project/endpoints/courses/join_codes/join_codes_utils.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -This module contains functions that are used by the join codes resources. -""" - -from project.endpoints.courses.courses_utils import get_course_abort_if_not_found - -def check_course_exists(func): - """ - Middleware to check if the course exists before handling the request - """ - def wrapper(*args, **kwargs): - get_course_abort_if_not_found(kwargs["course_id"]) - return func(*args, **kwargs) - return wrapper diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 691aacf0..c9d9dc03 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -12,7 +12,8 @@ from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model -from project.utils.authentication import authorize_teacher_or_project_admin, authorize_teacher_of_project, authorize_project_visible +from project.utils.authentication import authorize_teacher_or_project_admin, \ + authorize_teacher_of_project, authorize_project_visible API_URL = getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 34ae2282..a12aa578 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -14,7 +14,9 @@ from project.utils.files import filter_files, all_files_uploaded, zip_files from project.utils.user import is_valid_user from project.utils.project import is_valid_project -from project.utils.authentication import authorize_submission_request, authorize_submissions_request, authorize_grader, authorize_student_submission, authorize_submission_author +from project.utils.authentication import authorize_submission_request, \ + authorize_submissions_request, authorize_grader, \ + authorize_student_submission, authorize_submission_author load_dotenv() API_HOST = getenv("API_HOST") @@ -276,4 +278,4 @@ def delete(self, submission_id: int) -> dict[str, any]: submissions_bp.add_url_rule( "/submissions/", view_func=SubmissionEndpoint.as_view("submission") -) \ No newline at end of file +) diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 1e46994e..7d073c6c 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -8,7 +8,8 @@ from project import db from project.models.user import User as userModel -from project.utils.authentication import login_required, authorize_user, not_allowed +from project.utils.authentication import login_required, authorize_user, \ + authorize_admin, not_allowed users_bp = Blueprint("users", __name__) users_api = Api(users_bp) @@ -48,7 +49,6 @@ def get(self): @not_allowed def post(self): - # TODO make it so this just creates a user for yourself """ This function will respond to post requests made to /users. It should create a new user and return a success message. @@ -56,6 +56,7 @@ def post(self): uid = request.json.get('uid') is_teacher = request.json.get('is_teacher') is_admin = request.json.get('is_admin') + url = f"{API_URL}/users" if is_teacher is None or is_admin is None or uid is None: return { @@ -64,25 +65,24 @@ def post(self): "uid": "User ID (string)", "is_teacher": "Teacher status (boolean)", "is_admin": "Admin status (boolean)" - },"url": f"{API_URL}/users" + },"url": url }, 400 try: user = db.session.get(userModel, uid) if user is not None: - # bad request, error code could be 409 but is rarely used + # Bad request, error code could be 409 but is rarely used return {"message": f"User {uid} already exists"}, 400 # Code to create a new user in the database using the uid, is_teacher, and is_admin new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) db.session.add(new_user) db.session.commit() return jsonify({"message": "User created successfully!", - "data": user, "url": f"{API_URL}/users/{user.uid}", "status_code": 201}) + "data": user, "url": f"{url}/{user.uid}", "status_code": 201}) except SQLAlchemyError: - # every exception should result in a rollback db.session.rollback() return {"message": "An error occurred while creating the user", - "url": f"{API_URL}/users"}, 500 + "url": url}, 500 class User(Resource): @@ -105,7 +105,7 @@ def get(self, user_id): return {"message": "An error occurred while fetching the user", "url": f"{API_URL}/users"}, 500 - @not_allowed + @authorize_admin def patch(self, user_id): """ Update the user's information. diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index 6501da9e..61b64e61 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -3,36 +3,33 @@ """ from os import getenv +from functools import wraps + from dotenv import load_dotenv -from functools import wraps from flask import abort, request, make_response import requests +from sqlalchemy.exc import SQLAlchemyError from project import db from project.models.user import User -from project.models.course import Course -from project.models.project import Project -from project.models.submission import Submission -from project.models.course_relation import CourseAdmin, CourseStudent -from sqlalchemy.exc import SQLAlchemyError +from project.utils.models.course_utils import is_admin_of_course, \ + is_student_of_course, is_teacher_of_course +from project.utils.models.project_utils import get_course_of_project, project_visible +from project.utils.models.submission_utils import get_submission, get_course_of_submission +from project.utils.models.user_utils import is_admin, is_teacher load_dotenv() API_URL = getenv("API_HOST") AUTHENTICATION_URL = getenv("AUTHENTICATION_URL") -def abort_with_message(code: int, message: str): - """Helper function to abort with a given status code and message""" - abort(make_response({"message": message}, code)) - - def not_allowed(f): """Decorator function to immediately abort the current request and return 403: Forbidden""" @wraps(f) def wrap(*args, **kwargs): - abort_with_message(403, "Forbidden action") + return {"message":"Forbidden action"}, 403 return wrap @@ -42,144 +39,47 @@ def return_authenticated_user_id(): """ authentication = request.headers.get("Authorization") if not authentication: - abort_with_message(401, "No authorization given, you need an access token to use this API") - + abort(make_response(({"message": + "No authorization given, you need an access token to use this API"} + , 401))) + auth_header = {"Authorization": authentication} - response = requests.get(AUTHENTICATION_URL, headers=auth_header) - if not response: - abort_with_message(401, "An error occured while trying to authenticate your access token") - if response.status_code != 200: - abort_with_message(401, response.json()["error"]) + try: + response = requests.get(AUTHENTICATION_URL, headers=auth_header, timeout=5) + except TimeoutError: + abort(make_response(({"message":"Request to Microsoft timed out"} + , 500))) + if not response or response.status_code != 200: + abort(make_response(({"message": + "An error occured while trying to authenticate your access token"} + , 401))) user_info = response.json() auth_user_id = user_info["id"] try: user = db.session.get(User, auth_user_id) except SQLAlchemyError: - # every exception should result in a rollback db.session.rollback() - return abort_with_message(500, "An unexpected database error occured while fetching the user") - + abort(make_response(({"message": + "An unexpected database error occured while fetching the user"}, 500))) + if user: return auth_user_id is_teacher = False - if user_info["jobTitle"] != None: + if user_info["jobTitle"] is not None: is_teacher = True - + # add user if not yet in database try: new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) db.session.add(new_user) db.session.commit() except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return abort_with_message(500, "An unexpected database error occured while creating the user during authentication") - return auth_user_id - - -def is_teacher(auth_user_id): - """This function checks whether the user with auth_user_id is a teacher""" - try: - user = db.session.get(User, auth_user_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - if not user: # should realistically never happen - abort(500, "A database error occured") - if user.is_teacher: - return True - return False - - -def is_teacher_of_course(auth_user_id, course_id): - """This function checks whether the user with auth_user_id is the teacher of the course: course_id""" - try: - course = db.session.get(Course, course_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - - if not course: - abort_with_message(404, f"Could not find course with id: {course_id}") - - if auth_user_id == course.teacher: - return True - - -def is_admin_of_course(auth_user_id, course_id): - """This function checks whether the user with auth_user_id is an admin of the course: course_id""" - try: - course_admin = db.session.get(CourseAdmin, (course_id, auth_user_id)) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - - if course_admin: - return True - - return False - - -def is_student_of_course(auth_user_id, course_id): - """This function checks whether the user with auth_user_id is a student of the course: course_id""" - try: - course_student = db.session.get(CourseStudent, (course_id, auth_user_id)) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the user", - "url": f"{API_URL}/users"}, 500 - if course_student: - return True - return False - - -def get_course_of_project(project_id): - """This function returns the course_id of the course associated with the project: project_id""" - try: - project = db.session.get(Project, project_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - return {"message": "An error occurred while fetching the project", - "url": f"{API_URL}/users"}, 500 - - if not project: - abort_with_message(404, f"Could not find project with id: {project_id}") - - return project.course_id - - -def project_visible(project_id): - try: - project = db.session.get(Project, project_id) - except SQLAlchemyError: - # every exception should result in a rollback db.session.rollback() - abort_with_message(500, "An error occurred while fetching the project") - if not project: - abort_with_message(404, "Project with given id not found") - return project.visible_for_students - - -def get_course_of_submission(submission_id): - try: - submission = db.session.get(Submission, submission_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - abort_with_message(500, "An error occurred while fetching the submission") - if not submission: - abort_with_message(404, f"Submission with id: {submission_id} not found") - return get_course_of_project(submission.project_id) - + abort(make_response(({"message": + """An unexpected database error occured + while creating the user during authentication"""}, 500))) + return auth_user_id def login_required(f): """ @@ -193,6 +93,22 @@ def wrap(*args, **kwargs): return wrap +def authorize_admin(f): + """ + This function will check if the person sending a request to the API is logged in and an admin. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + if is_admin(auth_user_id): + return f(*args, **kwargs) + abort(make_response(({"message": + """You are not authorized to perfom this action, + only admins are authorized"""}, 403))) + return wrap + + def authorize_teacher(f): """ This function will check if the person sending a request to the API is logged in and a teacher. @@ -204,7 +120,9 @@ def wrap(*args, **kwargs): if is_teacher(auth_user_id): kwargs["teacher_id"] = auth_user_id return f(*args, **kwargs) - abort_with_message(403, "You are not authorized to perfom this action, only teachers are authorized") + abort(make_response(({"message": + """You are not authorized to perfom this action, + only teachers are authorized"""}, 403))) return wrap @@ -220,7 +138,7 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, kwargs["course_id"]): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap @@ -234,10 +152,12 @@ def authorize_teacher_or_course_admin(f): def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = kwargs["course_id"] - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, "You are not authorized to perfom this action, only teachers and course admins are authorized") + abort(make_response(({"message":"""You are not authorized to perfom this action, + only teachers and course admins are authorized"""}, 403))) return wrap @@ -253,8 +173,9 @@ def wrap(*args, **kwargs): user_id = kwargs["user_id"] if auth_user_id == user_id: return f(*args, **kwargs) - - abort_with_message(403, "You are not authorized to perfom this action, you are not this user") + + abort(make_response(({"message":"""You are not authorized to perfom this action, + you are not this user"""}, 403))) return wrap @@ -269,11 +190,12 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) - + if is_teacher_of_course(auth_user_id, course_id): return f(*args, **kwargs) - abort_with_message(403, "You are not authorized to perfom this action, you are not the teacher of this project") + abort(make_response(({"message":"""You are not authorized to perfom this action, + you are not the teacher of this project"""}, 403))) return wrap @@ -288,10 +210,11 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, """You are not authorized to perfom this action, - you are not the teacher or an admin of this project""") + abort(make_response(({"message":"""You are not authorized to perfom this action, + you are not the teacher or an admin of this project"""}, 403))) return wrap @@ -299,7 +222,8 @@ def authorize_project_visible(f): """ This function will check if the person sending a request to the API is logged in, and the teacher of the course which the project in the request belongs to. - Or if the person is a student of this course, it will return the project if it is visible for students. + Or if the person is a student of this course, + it will return the project if it is visible for students. Returns 403: Not Authorized if either condition is false """ @wraps(f) @@ -307,89 +231,101 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_submissions_request(f): + """This function will check if the person sending a request to the API is logged in, + and either the teacher/admin of the course or the student + that the submission belongs to + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = request.args["project_id"] course_id = get_course_of_project(project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - - if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.args.get("uid"): + + if (is_student_of_course(auth_user_id, course_id) + and project_visible(project_id) + and auth_user_id == request.args.get("uid")): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_student_submission(f): + """This function will check if the person sending a request to the API is logged in, + and a student of the course they're trying to post a submission to + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() project_id = request.form["project_id"] course_id = get_course_of_project(project_id) - if is_student_of_course(auth_user_id, course_id) and project_visible(project_id) and auth_user_id == request.form.get("uid"): + if (is_student_of_course(auth_user_id, course_id) + and project_visible(project_id) + and auth_user_id == request.form.get("uid")): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_submission_author(f): + """This function will check if the person sending a request to the API is logged in, + and the original author of the submission + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() submission_id = kwargs["submission_id"] - try: - submission = db.session.get(Submission, submission_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - abort_with_message(500, "An error occurred while fetching the submission") - if not submission: - abort_with_message(404, f"Submission with id: {submission_id} not found") + submission = get_submission(submission_id) if submission.uid == auth_user_id: return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_grader(f): + """This function will check if the person sending a request to the API is logged in, + and either the teacher/admin of the course. + """ @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = get_course_of_submission(kwargs["submission_id"]) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) return wrap def authorize_submission_request(f): + """This function will check if the person sending a request to the API is logged in, + and either the teacher/admin of the course or the student + that the submission belongs to + """ @wraps(f) def wrap(*args, **kwargs): - # submission_author / grader mag hier aan auth_user_id = return_authenticated_user_id() submission_id = kwargs["submission_id"] - try: - submission = db.session.get(Submission, submission_id) - except SQLAlchemyError: - # every exception should result in a rollback - db.session.rollback() - abort_with_message(500, "An error occurred while fetching the submission") - if not submission: - abort_with_message(404, f"Submission with id: {submission_id} not found") + submission = get_submission(submission_id) if submission.uid == auth_user_id: return f(*args, **kwargs) course_id = get_course_of_project(submission.project_id) - if is_teacher_of_course(auth_user_id, course_id) or is_admin_of_course(auth_user_id, course_id): + if (is_teacher_of_course(auth_user_id, course_id) + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort_with_message(403, "You're not authorized to perform this action") + abort(make_response(({"message": + "You're not authorized to perform this action"} + , 403))) return wrap diff --git a/backend/project/utils/models/course_utils.py b/backend/project/utils/models/course_utils.py new file mode 100644 index 00000000..6ea09afd --- /dev/null +++ b/backend/project/utils/models/course_utils.py @@ -0,0 +1,67 @@ +"""This module contains helper functions related to courses for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.course import Course +from project.models.course_relation import CourseAdmin, CourseStudent + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_course(course_id): + """Returns the course associated with course_id or the appropriate error""" + try: + course = db.session.get(Course, course_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500))) + + if not course: + abort(make_response(({"message":f"Course with id: {course_id} not found"}, 404))) + return course + +def is_teacher_of_course(auth_user_id, course_id): + """This function checks whether the user + with auth_user_id is the teacher of the course: course_id + """ + course = get_course(course_id) + if auth_user_id == course.teacher: + return True + return False + + +def is_admin_of_course(auth_user_id, course_id): + """This function checks whether the user + with auth_user_id is an admin of the course: course_id + """ + try: + course_admin = db.session.get(CourseAdmin, (course_id, auth_user_id)) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500))) + + if course_admin: + return True + return False + +def is_student_of_course(auth_user_id, course_id): + """This function checks whether the user + with auth_user_id is a student of the course: course_id + """ + try: + course_student = db.session.get(CourseStudent, (course_id, auth_user_id)) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user", + "url": f"{API_URL}/users"}, 500))) + if course_student: + return True + return False diff --git a/backend/project/utils/models/project_utils.py b/backend/project/utils/models/project_utils.py new file mode 100644 index 00000000..2e2b9f17 --- /dev/null +++ b/backend/project/utils/models/project_utils.py @@ -0,0 +1,41 @@ +"""This module contains helper functions related to projects for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.project import Project + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_project(project_id): + """Returns the project associated with project_id or the appropriate error""" + if isinstance(project_id, str) and not project_id.isnumeric(): + abort(make_response(({"message": f"{project_id} is not a valid project id"} + , 400))) + try: + project = db.session.get(Project, project_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the project"} + , 500))) + + if not project: + abort(make_response(({"message":f"Project with id: {project_id} not found"}, 404))) + + return project + +def get_course_of_project(project_id): + """This function returns the course_id of the course associated with the project: project_id""" + project = get_project(project_id) + return project.course_id + +def project_visible(project_id): + """Determine whether a project is visible for students""" + project = get_project(project_id) + return project.visible_for_students diff --git a/backend/project/utils/models/submission_utils.py b/backend/project/utils/models/submission_utils.py new file mode 100644 index 00000000..5cd46a68 --- /dev/null +++ b/backend/project/utils/models/submission_utils.py @@ -0,0 +1,33 @@ +"""This module contains helper functions related to submissions for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.submission import Submission +from project.utils.models.project_utils import get_course_of_project + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_submission(submission_id): + """Returns the submission associated with submission_id or the appropriate error""" + try: + submission = db.session.get(Submission, submission_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message":"An error occurred while fetching the submission"}, 500))) + + if not submission: + abort(make_response(({"message":f"Submission with id: {submission_id} not found"}, 404))) + + return submission + +def get_course_of_submission(submission_id): + """Get the course linked to a given submission""" + submission = get_submission(submission_id) + return get_course_of_project(submission.project_id) diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py new file mode 100644 index 00000000..f601c8b3 --- /dev/null +++ b/backend/project/utils/models/user_utils.py @@ -0,0 +1,36 @@ +"""This module contains helper functions related to users for accessing the database""" + +from os import getenv + +from dotenv import load_dotenv + +from flask import abort, make_response +from sqlalchemy.exc import SQLAlchemyError + +from project import db +from project.models.user import User + +load_dotenv() +API_URL = getenv("API_HOST") + +def get_user(user_id): + """Returns the user associated with user_id or the appropriate error""" + try: + user = db.session.get(User, user_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": "An error occurred while fetching the user"} + , 500))) + if not user: + abort(make_response(({"message":f"User with id: {user_id} not found"}, 404))) + return user + +def is_teacher(auth_user_id): + """This function checks whether the user with auth_user_id is a teacher""" + user = get_user(auth_user_id) + return user.is_teacher + +def is_admin(auth_user_id): + """This function checks whether the user with auth_user_id is a teacher""" + user = get_user(auth_user_id) + return user.is_admin diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index d9f7d9cd..745006a1 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -232,4 +232,4 @@ def patch_by_id_from_model(model: DeclarativeMeta, "url": urljoin(f"{base_url}/", str(column_id))}), 200 except SQLAlchemyError: return {"error": "Something went wrong while updating the database.", - "url": base_url}, 500 \ No newline at end of file + "url": base_url}, 500 diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index 2544968d..5d13b637 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -1,12 +1,10 @@ """Main entry point for the application.""" - from dotenv import load_dotenv from flask import Flask - -"""Index api point""" from flask import Blueprint, request from flask_restful import Resource, Api + index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) @@ -43,27 +41,30 @@ "id":"student02", "jobTitle":None }, + "admin1":{ + "id":"admin_person", + "jobTitle":"admin" + } } class Index(Resource): """Api endpoint for the / route""" def get(self): + "Returns the data associated with the authorization bearer token" auth = request.headers.get("Authorization") if not auth: return {"error":"Please give authorization"}, 401 - if auth in token_dict.keys(): + if token_dict.get(auth, None): return token_dict[auth], 200 return {"error":"Wrong address"}, 401 - -index_bp.add_url_rule("/", view_func=Index.as_view("index")) -if __name__ == "__main__": - load_dotenv() +index_bp.add_url_rule("/", view_func=Index.as_view("index")) - app = Flask(__name__) - app.register_blueprint(index_bp) +load_dotenv() - app.run(debug=True, host='0.0.0.0') +app = Flask(__name__) +app.register_blueprint(index_bp) +app.run(debug=True, host='0.0.0.0') diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 7be87a8c..aebe7ce9 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -153,4 +153,4 @@ def session(): # Truncate all tables for table in reversed(db.metadata.sorted_tables): session.execute(table.delete()) - session.commit() \ No newline at end of file + session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 4466ee92..d3a32c9a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -6,6 +6,7 @@ from zoneinfo import ZoneInfo import pytest from sqlalchemy import create_engine +from sqlalchemy.exc import SQLAlchemyError from project.models.user import User from project.models.course import Course from project.models.course_share_code import CourseShareCode @@ -59,6 +60,27 @@ def valid_user_entry(session, valid_user): session.commit() return user +@pytest.fixture +def valid_admin(): + """ + Returns a valid admin user form + """ + return { + "uid": "admin_person", + "is_teacher": False, + "is_admin":True + } + +@pytest.fixture +def valid_admin_entry(session, valid_admin): + """ + Returns an admin user that is in the database + """ + user = User(**valid_admin) + session.add(user) + session.commit() + return user + @pytest.fixture def user_invalid_field(valid_user): """ @@ -177,9 +199,12 @@ def client(app): @pytest.fixture def valid_teacher_entry(session): """A valid teacher for testing that's already in the db""" - teacher = User(uid="Bart", is_teacher=True) - session.add(teacher) - session.commit() + teacher = User(uid="Bart", is_teacher=True, is_admin=False) + try: + session.add(teacher) + session.commit() + except SQLAlchemyError: + session.rollback() return teacher @pytest.fixture diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 3d5e199f..0249559a 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -15,7 +15,8 @@ def test_post_courses(self, client, valid_course): assert data["data"]["teacher"] == valid_course["teacher"] # Is reachable using the API - get_response = client.get(f"/courses/{data['data']['course_id']}", headers={"Authorization":"teacher2"}) + get_response = client.get(f"/courses/{data['data']['course_id']}", + headers={"Authorization":"teacher2"}) assert get_response.status_code == 200 @@ -58,7 +59,8 @@ def test_course_delete(self, valid_course_entry, client): assert response.status_code == 200 # Is not reachable using the API - get_response = client.get(f"/courses/{valid_course_entry.course_id}", headers={"Authorization":"teacher2"}) + get_response = client.get(f"/courses/{valid_course_entry.course_id}", + headers={"Authorization":"teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index f199ab06..2df488fa 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -9,22 +9,24 @@ class TestCourseShareLinks: and everyone should be able to list all students assigned to a course """ - def test_get_share_links(self, valid_course_entry, client): + def test_get_share_links(self, client, valid_course_entry): """Test whether the share links are accessible""" - response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", headers={"Authorization":"teacher2"}) + response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", + headers={"Authorization":"teacher2"}) assert response.status_code == 200 - def test_post_share_links(self, valid_course_entry, client): + def test_post_share_links(self, client, valid_course_entry): """Test whether the share links are accessible to post to""" response = client.post( f"courses/{valid_course_entry.course_id}/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher2"}) assert response.status_code == 201 - def test_delete_share_links(self, share_code_admin, client): + def test_delete_share_links(self, client, share_code_admin): """Test whether the share links are accessible to delete""" response = client.delete( - f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", headers={"Authorization":"teacher2"}) + f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", + headers={"Authorization":"teacher2"}) assert response.status_code == 200 def test_get_share_links_404(self, client): @@ -34,10 +36,14 @@ def test_get_share_links_404(self, client): def test_post_share_links_404(self, client): """Test whether the share links are accessible to post to""" - response = client.post("courses/0/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher2"}) + response = client.post("courses/0/join_codes", + json={"for_admins": True}, + headers={"Authorization":"teacher2"}) assert response.status_code == 404 - def test_for_admins_required(self, valid_course_entry, client): + def test_for_admins_required(self, client, valid_course_entry): """Test whether the for_admins field is required""" - response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}, headers={"Authorization":"teacher2"}) + response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", + json={}, + headers={"Authorization":"teacher2"}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index fb9be82c..2cda69b6 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -16,7 +16,8 @@ def test_assignment_download(client, valid_project): ) assert response.status_code == 201 project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignments", headers={"Authorization":"teacher2"}) + response = client.get(f"/projects/{project_id}/assignments", + headers={"Authorization":"teacher2"}) # file downloaded succesfully assert response.status_code == 200 @@ -76,9 +77,7 @@ def test_remove_project(client, valid_project_entry): assert response.status_code == 404 def test_patch_project(client, valid_project_entry): - """ - Test functionality of the PATCH method for projects - """ + """Test functionality of the PATCH method for projects""" project_id = valid_project_entry.project_id diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 60fd971a..be528dae 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -19,7 +19,8 @@ def test_get_submissions_wrong_user(self, client: FlaskClient): def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" - response = client.get("/submissions?project_id=-1", headers={"Authorization":"teacher1"}) + response = client.get("/submissions?project_id=123456789", + headers={"Authorization":"teacher1"}) assert response.status_code == 404 # can't find course of project in authorization assert "message" in response.json @@ -31,7 +32,8 @@ def test_get_submissions_wrong_project_type(self, client: FlaskClient): def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" - response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", headers={"Authorization":"teacher2"}) + response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", + headers={"Authorization":"teacher2"}) data = response.json assert response.status_code == 200 assert "message" in data @@ -51,7 +53,8 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.get(f"/submissions/{submission.submission_id}", headers={"Authorization":"ad3_teacher"}) + response = client.get(f"/submissions/{submission.submission_id}", + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" @@ -68,7 +71,8 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" - response = client.patch("/submissions/0", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) + response = client.patch("/submissions/0", data={"grading": 20}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 404 assert data["message"] == "Submission with id: 0 not found" @@ -79,7 +83,9 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}, headers={"Authorization":"ad3_teacher"}) + response = client.patch(f"/submissions/{submission.submission_id}", + data={"grading": 100}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -90,7 +96,9 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}",data={"grading": "zero"}, headers={"Authorization":"ad3_teacher"}) + response = client.patch(f"/submissions/{submission.submission_id}", + data={"grading": "zero"}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -101,7 +109,9 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() - response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}, headers={"Authorization":"ad3_teacher"}) + response = client.patch(f"/submissions/{submission.submission_id}", + data={"grading": 20}, + headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" @@ -115,8 +125,6 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se "path": "/submissions/2", "status": False } - - # TODO test course admin (allowed) and student (not allowed) patch ### DELETE SUBMISSION ### def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -132,7 +140,8 @@ def test_delete_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() - response = client.delete(f"submissions/{submission.submission_id}", headers={"Authorization":"student01"}) + response = client.delete(f"submissions/{submission.submission_id}", + headers={"Authorization":"student01"}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 25b28d62..c6044db2 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -38,29 +38,34 @@ def user_db_session(): session.execute(table.delete()) session.commit() + class TestUserEndpoint: """Class to test user management endpoints.""" def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user - response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"student1"}) + response = client.delete(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"student1"}) assert response.status_code == 200 # If student 1 sends this request, he would get added again - get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) - + get_response = client.get(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"teacher1"}) + assert get_response.status_code == 404 - + def test_delete_user_not_yourself(self, client, valid_user_entry): """Test deleting a user that is not the user the authentication belongs to.""" # Delete the user - response = client.delete(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + response = client.delete(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"teacher1"}) assert response.status_code == 403 # If student 1 sends this request, he would get added again - get_response = client.get(f"/users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) - + get_response = client.get(f"/users/{valid_user_entry.uid}", + headers={"Authorization":"teacher1"}) + assert get_response.status_code == 200 def test_delete_not_present(self, client): @@ -75,7 +80,8 @@ def test_post_no_authentication(self, client, user_invalid_field): def test_post_authenticated(self, client, valid_user): """Test posting with wrong authentication.""" - response = client.post("/users", data=valid_user, headers={"Authorization":"teacher1"}) + response = client.post("/users", data=valid_user, + headers={"Authorization":"teacher1"}) assert response.status_code == 403 # POST to /users is not allowed def test_get_all_users(self, client, valid_user_entries): @@ -85,14 +91,13 @@ def test_get_all_users(self, client, valid_user_entries): # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) for valid_user in valid_user_entries: - assert valid_user.uid in \ - [user["uid"] for user in response.json["data"]] - + assert valid_user.uid in [user["uid"] for user in response.json["data"]] + def test_get_all_users_no_authentication(self, client): """Test getting all users without authentication.""" response = client.get("/users") assert response.status_code == 401 - + def test_get_all_users_wrong_authentication(self, client): """Test getting all users with wrong authentication.""" response = client.get("/users", headers={"Authorization":"wrong"}) @@ -103,18 +108,28 @@ def test_get_one_user(self, client, valid_user_entry): response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) assert response.status_code == 200 assert "data" in response.json - + def test_get_one_user_no_authentication(self, client, valid_user_entry): """Test getting a single user without authentication.""" response = client.get(f"users/{valid_user_entry.uid}") assert response.status_code == 401 - + def test_get_one_user_wrong_authentication(self, client, valid_user_entry): """Test getting a single user with wrong authentication.""" response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"wrong"}) assert response.status_code == 401 - def test_patch_user(self, client, valid_user_entry): + def test_patch_user_not_authorized(self, client, valid_admin_entry, valid_user_entry): + """Test trying to patch a user without authorization""" + new_is_teacher = not valid_user_entry.is_teacher + + response = client.patch(f"/users/{valid_user_entry.uid}", json={ + 'is_teacher': new_is_teacher, + 'is_admin': not valid_user_entry.is_admin + }, headers={"Authorization":"student01"}) + assert response.status_code == 403 # Patching a user is not allowed as a not-admin + + def test_patch_user(self, client, valid_admin_entry, valid_user_entry): """Test updating a user.""" new_is_teacher = not valid_user_entry.is_teacher @@ -122,28 +137,30 @@ def test_patch_user(self, client, valid_user_entry): response = client.patch(f"/users/{valid_user_entry.uid}", json={ 'is_teacher': new_is_teacher, 'is_admin': not valid_user_entry.is_admin - }) - assert response.status_code == 403 # Patching a user is never necessary and thus not allowed + }, headers={"Authorization":"admin1"}) + assert response.status_code == 200 - def test_patch_non_existent(self, client): + def test_patch_non_existent(self, client, valid_admin_entry): """Test updating a non-existent user.""" response = client.patch("/users/-20", json={ 'is_teacher': False, 'is_admin': True - }) - assert response.status_code == 403 # Patching is not allowed + }, headers={"Authorization":"admin1"}) + assert response.status_code == 404 - def test_patch_non_json(self, client, valid_user_entry): + def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] - response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form) - assert response.status_code == 403 # Patching is not allowed + response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form, + headers={"Authorization":"admin1"}) + assert response.status_code == 415 def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false", headers={"Authorization":"teacher1"}) + response = client.get("/users?is_admin=true&is_teacher=false", + headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query From d247551b1792b69bedf93fcd1c671c04d8a3705b Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:21:07 +0100 Subject: [PATCH 206/377] Enhancement/debug env variable (#128) * added waitress te requirements to serve in production * added debug as an env variable and serving with waitress when not in debug mode --- backend/project/__main__.py | 12 ++++++++++-- backend/requirements.txt | 3 ++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/project/__main__.py b/backend/project/__main__.py index 444d1410..8a965261 100644 --- a/backend/project/__main__.py +++ b/backend/project/__main__.py @@ -1,10 +1,18 @@ """Main entry point for the application.""" +from os import getenv from dotenv import load_dotenv from project import create_app_with_db from project.db_in import url +load_dotenv() +DEBUG=getenv("DEBUG") + if __name__ == "__main__": - load_dotenv() app = create_app_with_db(url) - app.run(debug=True, host='0.0.0.0') + + if DEBUG and DEBUG.lower() == "true": + app.run(debug=True, host='0.0.0.0') + else: + from waitress import serve + serve(app, host='0.0.0.0', port=5000) diff --git a/backend/requirements.txt b/backend/requirements.txt index 9e9dc90a..a5d3cd59 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,4 +5,5 @@ python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 SQLAlchemy~=2.0.27 -requests~=2.25.1 \ No newline at end of file +requests~=2.25.1 +waitress From 5283f0c2711bee8825c242d8315d9acaaec42e29 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:27:09 +0100 Subject: [PATCH 207/377] fixed backend to frontend typo and added workflow branch argument (#119) * fixed backend to frontend typo and added workflow branch argument * added back 2. back to readme of backend --- backend/README.md | 4 ++-- frontend/README.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/README.md b/backend/README.md index b7cd5ee4..d978ac22 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Project pigeonhole backend -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) ## Prerequisites **1. Clone the repo** ```sh diff --git a/frontend/README.md b/frontend/README.md index 5f81d16b..6d217e0b 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ -# Project pigeonhole backend -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) +# Project pigeonhole frontend +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) ## Prerequisites **1. Clone the repo** ```sh From 3d169f88970f7ebd33df6f485721d734aa1c3fd1 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:36:19 +0100 Subject: [PATCH 208/377] changed submission status to enum (#133) * changed submission status to enum * resolved linting --- backend/db_construct.sql | 4 +++- backend/project/endpoints/submissions.py | 4 ++-- backend/project/models/submission.py | 21 +++++++++++++++++++-- backend/tests.yaml | 2 +- backend/tests/conftest.py | 8 ++++---- backend/tests/endpoints/conftest.py | 4 ++-- backend/tests/endpoints/submissions_test.py | 4 ++-- backend/tests/models/submission_test.py | 4 ++-- 8 files changed, 35 insertions(+), 16 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index a1ad51fe..3713788d 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,3 +1,5 @@ +CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); + CREATE TABLE users ( uid VARCHAR(255), is_teacher BOOLEAN, @@ -58,7 +60,7 @@ CREATE TABLE submissions ( grading INTEGER CHECK (grading >= 0 AND grading <= 20), submission_time TIMESTAMP WITH TIME ZONE NOT NULL, submission_path VARCHAR(50) NOT NULL, - submission_status BOOLEAN NOT NULL, + submission_status submission_status NOT NULL, PRIMARY KEY(submission_id), CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, CONSTRAINT fk_user FOREIGN KEY(uid) REFERENCES users(uid) ON DELETE CASCADE diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index a12aa578..48403501 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -8,7 +8,7 @@ from flask_restful import Resource from sqlalchemy import exc from project.db_in import db -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus from project.models.project import Project from project.models.user import User from project.utils.files import filter_files, all_files_uploaded, zip_files @@ -121,7 +121,7 @@ def post(self) -> dict[str, any]: zip_file.save(path.join(f"{UPLOAD_FOLDER}/", submission.submission_path)) # Submission status - submission.submission_status = False + submission.submission_status = SubmissionStatus.RUNNING session.add(submission) session.commit() diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index cda2620d..8768dd99 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -1,9 +1,24 @@ """Submission model""" from dataclasses import dataclass -from sqlalchemy import Column, String, ForeignKey, Integer, CheckConstraint, DateTime, Boolean +from enum import Enum +from sqlalchemy import ( + Column, + String, + ForeignKey, + Integer, + CheckConstraint, + DateTime, + Enum as EnumField) from project.db_in import db +class SubmissionStatus(str, Enum): + """Enum for submission status""" + SUCCESS = 'SUCCESS' + LATE = 'LATE' + FAIL = 'FAIL' + RUNNING = 'RUNNING' + @dataclass class Submission(db.Model): """This class describes the submissions table, @@ -23,4 +38,6 @@ class Submission(db.Model): grading: int = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) submission_time: DateTime = Column(DateTime(timezone=True), nullable=False) submission_path: str = Column(String(50), nullable=False) - submission_status: bool = Column(Boolean, nullable=False) + submission_status: SubmissionStatus = Column( + EnumField(SubmissionStatus, name="submission_status"), + nullable=False) diff --git a/backend/tests.yaml b/backend/tests.yaml index d1a41efb..6238d2ec 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -18,7 +18,7 @@ services: auth-server: build: context: . - dockerfile: ./Dockerfile_auth_test + dockerfile: Dockerfile_auth_test environment: API_HOST: http://auth-server volumes: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index aebe7ce9..cc605602 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,7 +9,7 @@ from project.models.user import User from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus @pytest.fixture def db_session(): @@ -104,14 +104,14 @@ def submissions(session): grading=16, submission_time=datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/1", - submission_status=True + submission_status= SubmissionStatus.SUCCESS ), Submission( uid="student02", project_id=project_id_ad3, submission_time=datetime(2024,3,14,23,59,59,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/2", - submission_status=False + submission_status= SubmissionStatus.FAIL ), Submission( uid="student02", @@ -119,7 +119,7 @@ def submissions(session): grading=15, submission_time=datetime(2023,3,5,10,0,0,tzinfo=ZoneInfo("GMT")), submission_path="/submissions/3", - submission_status=True + submission_status= SubmissionStatus.SUCCESS ) ] diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index d3a32c9a..3f234b62 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -12,7 +12,7 @@ from project.models.course_share_code import CourseShareCode from project import create_app_with_db from project.db_in import url, db -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus from project.models.project import Project @@ -27,7 +27,7 @@ def valid_submission(valid_user_entry, valid_project_entry): "grading": 16, "submission_time": datetime(2024,3,14,12,0,0,tzinfo=ZoneInfo("GMT")), "submission_path": "/submission/1", - "submission_status": True + "submission_status": SubmissionStatus.SUCCESS } @pytest.fixture diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index be528dae..9ef6392f 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -65,7 +65,7 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): "grading": 16, "time": "Thu, 14 Mar 2024 12:00:00 GMT", "path": "/submissions/1", - "status": True + "status": 'SUCCESS' } ### PATCH SUBMISSION ### @@ -123,7 +123,7 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se "grading": 20, "time": 'Thu, 14 Mar 2024 23:59:59 GMT', "path": "/submissions/2", - "status": False + "status": 'FAIL' } ### DELETE SUBMISSION ### diff --git a/backend/tests/models/submission_test.py b/backend/tests/models/submission_test.py index 66a2779b..28918c5e 100644 --- a/backend/tests/models/submission_test.py +++ b/backend/tests/models/submission_test.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from project.models.project import Project -from project.models.submission import Submission +from project.models.submission import Submission, SubmissionStatus class TestSubmissionModel: """Class to test the Submission model""" @@ -18,7 +18,7 @@ def test_create_submission(self, session: Session): project_id=project.project_id, submission_time=datetime(2023,3,15,13,0,0), submission_path="/submissions", - submission_status=True + submission_status=SubmissionStatus.SUCCESS ) session.add(submission) session.commit() From cdb2dbaabde2c6bd7397aa28cf6d6be93f3fe9a2 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 14:57:33 +0100 Subject: [PATCH 209/377] Fix #77 changed grading to float (#134) --- backend/db_construct.sql | 2 +- backend/project/endpoints/submissions.py | 12 +++++++++--- backend/project/models/submission.py | 3 ++- backend/tests/endpoints/submissions_test.py | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index 3713788d..bb7c7eb7 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -57,7 +57,7 @@ CREATE TABLE submissions ( submission_id INT GENERATED ALWAYS AS IDENTITY, uid VARCHAR(255) NOT NULL, project_id INT NOT NULL, - grading INTEGER CHECK (grading >= 0 AND grading <= 20), + grading FLOAT CHECK (grading >= 0 AND grading <= 20), submission_time TIMESTAMP WITH TIME ZONE NOT NULL, submission_path VARCHAR(50) NOT NULL, submission_status submission_status NOT NULL, diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 48403501..ebc22a88 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -212,10 +212,16 @@ def patch(self, submission_id:int) -> dict[str, any]: # Update the grading field grading = request.form.get("grading") if grading is not None: - if not (grading.isdigit() and 0 <= int(grading) <= 20): - data["message"] = "Invalid grading (grading=0-20)" + try: + grading_float = float(grading) + if 0 <= grading_float <= 20: + submission.grading = grading_float + else: + data["message"] = "Invalid grading (grading=0-20)" + return data, 400 + except ValueError: + data["message"] = "Invalid grading (not a valid float)" return data, 400 - submission.grading = int(grading) # Save the submission session.commit() diff --git a/backend/project/models/submission.py b/backend/project/models/submission.py index 8768dd99..1587f80f 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -9,6 +9,7 @@ Integer, CheckConstraint, DateTime, + Float, Enum as EnumField) from project.db_in import db @@ -35,7 +36,7 @@ class Submission(db.Model): submission_id: int = Column(Integer, primary_key=True) uid: str = Column(String(255), ForeignKey("users.uid"), nullable=False) project_id: int = Column(Integer, ForeignKey("projects.project_id"), nullable=False) - grading: int = Column(Integer, CheckConstraint("grading >= 0 AND grading <= 20")) + grading: float = Column(Float, CheckConstraint("grading >= 0 AND grading <= 20")) submission_time: DateTime = Column(DateTime(timezone=True), nullable=False) submission_path: str = Column(String(50), nullable=False) submission_status: SubmissionStatus = Column( diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 9ef6392f..a900bb84 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -101,7 +101,7 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: headers={"Authorization":"ad3_teacher"}) data = response.json assert response.status_code == 400 - assert data["message"] == "Invalid grading (grading=0-20)" + assert data["message"] == "Invalid grading (not a valid float)" def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Session): """Test patching a submission""" From 8b4b093c7e112e41ef6393e901bd0905c3e24e36 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 21 Mar 2024 15:46:44 +0100 Subject: [PATCH 210/377] Fixed #112 (#113) * Fixed #112 * removed new line --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index a5d3cd59..1bbc2e9e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,5 +5,5 @@ python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 SQLAlchemy~=2.0.27 -requests~=2.25.1 +requests>=2.31.0 waitress From 2d0574c0250c39adba423e7615a9e2e4e924d2b8 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 21 Mar 2024 16:16:35 +0100 Subject: [PATCH 211/377] Fix for uploading project files (#120) * files are now patchable uncluding the files * fix for uploading a file that is not a zip that it doesn't get saved * unsaved changes * buzoghany requested changes * linter * added rollbacks * extra rollback --- .../endpoints/projects/project_detail.py | 55 +++++++++++++++++-- .../project/endpoints/projects/projects.py | 38 +++++++------ 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index c9d9dc03..060587c7 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -3,20 +3,27 @@ for example /projects/1 if the project id of the corresponding project is 1 """ -from os import getenv +import os +import zipfile from urllib.parse import urljoin from flask import request from flask_restful import Resource +from project.db_in import db + from project.models.project import Project from project.utils.query_agent import query_by_id_from_model, delete_by_id_from_model, \ patch_by_id_from_model from project.utils.authentication import authorize_teacher_or_project_admin, \ authorize_teacher_of_project, authorize_project_visible -API_URL = getenv('API_HOST') +from project.endpoints.projects.endpoint_parser import parse_project_params + +API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") +UPLOAD_FOLDER = os.getenv('UPLOAD_URL') + class ProjectDetail(Resource): """ @@ -45,14 +52,54 @@ def patch(self, project_id): Update method for updating a specific project filtered by id of that specific project """ + project_json = parse_project_params() - return patch_by_id_from_model( + output, status_code = patch_by_id_from_model( Project, "project_id", project_id, RESPONSE_URL, - request.json + project_json ) + if status_code != 200: + return output, status_code + + if "assignment_file" in request.files: + file = request.files["assignment_file"] + filename = os.path.basename(file.filename) + project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{project_id}") + os.makedirs(project_upload_directory, exist_ok=True) + try: + # remove the old file + try: + to_rem_files = os.listdir(project_upload_directory) + for to_rem_file in to_rem_files: + to_rem_file_path = os.path.join(project_upload_directory, to_rem_file) + if os.path.isfile(to_rem_file_path): + os.remove(to_rem_file_path) + except FileNotFoundError: + db.session.rollback() + return ({ + "message": "Something went wrong deleting the old project files", + "url": f"{API_URL}/projects/{project_id}" + }) + + # removed all files now upload the new files + file.save(os.path.join(project_upload_directory, filename)) + zip_location = os.path.join(project_upload_directory, filename) + with zipfile.ZipFile(zip_location) as upload_zip: + upload_zip.extractall(project_upload_directory) + project_json["assignment_file"] = filename + except zipfile.BadZipfile: + db.session.rollback() + return ({ + "message": + "Please provide a valid .zip file for updating the instructions", + "url": f"{API_URL}/projects/{project_id}" + }, + 400) + + return output, status_code @authorize_teacher_of_project def delete(self, project_id): diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ccbdca70..b0afa4f8 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -9,6 +9,8 @@ from flask import request, jsonify from flask_restful import Resource +from project.db_in import db + from project.models.project import Project from project.utils.query_agent import query_selected_from_model, create_model_instance from project.utils.authentication import authorize_teacher @@ -18,6 +20,7 @@ API_URL = os.getenv('API_HOST') UPLOAD_FOLDER = os.getenv('UPLOAD_URL') + class ProjectsEndpoint(Resource): """ Class for projects endpoints @@ -47,10 +50,12 @@ def post(self, teacher_id=None): using flask_restfull parse lib """ - file = request.files["assignment_file"] project_json = parse_project_params() - filename = os.path.basename(file.filename) - project_json["assignment_file"] = filename + filename = None + if "assignment_file" in request.files: + file = request.files["assignment_file"] + filename = os.path.basename(file.filename) + project_json["assignment_file"] = filename # save the file that is given with the request try: @@ -73,20 +78,21 @@ def post(self, teacher_id=None): return new_project, status_code project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") - os.makedirs(project_upload_directory, exist_ok=True) - - file.save(os.path.join(project_upload_directory, filename)) - try: - with zipfile.ZipFile(os.path.join(project_upload_directory, filename)) as upload_zip: - upload_zip.extractall(project_upload_directory) - except zipfile.BadZipfile: - return ({ - "message": "Please provide a .zip file for uploading the instructions", - "url": f"{API_URL}/projects" - }, - 400) - + if filename is not None: + try: + file.save(os.path.join(project_upload_directory, filename)) + zip_location = os.path.join(project_upload_directory, filename) + with zipfile.ZipFile(zip_location) as upload_zip: + upload_zip.extractall(project_upload_directory) + except zipfile.BadZipfile: + os.remove(os.path.join(project_upload_directory, filename)) + db.session.rollback() + return ({ + "message": "Please provide a .zip file for uploading the instructions", + "url": f"{API_URL}/projects" + }, + 400) return { "message": "Project created succesfully", "data": new_project, From f0b9b2084d28cac0b2bc15347d31004e63c64743 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 22 Mar 2024 11:48:45 +0100 Subject: [PATCH 212/377] Add get courses tests --- backend/run_tests.sh | 2 +- backend/tests/endpoints/conftest.py | 2 +- .../tests/endpoints/course/courses_test.py | 138 +++++++++++++++++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/backend/run_tests.sh b/backend/run_tests.sh index a35d5cb2..69bf531a 100755 --- a/backend/run_tests.sh +++ b/backend/run_tests.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Run Docker Compose to build and start the services, and capture the exit code from the test runner service docker-compose -f tests.yaml up --build --exit-code-from test-runner diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 3f234b62..3f044743 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -210,7 +210,7 @@ def valid_teacher_entry(session): @pytest.fixture def valid_course(valid_teacher_entry): """A valid course json form""" - return {"name": "Sel", "teacher": valid_teacher_entry.uid} + return {"name": "Sel", "ufora_id": "C003784A_2023", "teacher": valid_teacher_entry.uid} @pytest.fixture def course_no_name(valid_teacher_entry): diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 0249559a..895168a6 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,7 +1,141 @@ -"""Here we will test all the courses endpoint related functionality""" +"""Tests the courses API endpoint""" + +from flask.testing import FlaskClient + +AUTH_TOKEN_BAD = "" +AUTH_TOKEN_TEACHER = "teacher2" class TestCourseEndpoint: - """Class for testing the courses endpoint""" + """Class to test the courses API endpoint""" + + ### GET COURSES ### + def test_get_courses_not_authenticated(self, client: FlaskClient): + """Test getting courses when not authenticated""" + response = client.get("/courses") + assert response.status_code == 401 + + def test_get_courses_bad_authentication_token(self, client: FlaskClient): + """Test getting courses for a bad authentication token""" + response = client.get("/courses", headers = {"Authorization": AUTH_TOKEN_BAD}) + assert response.status_code == 401 + + def test_get_courses_all(self, client: FlaskClient, valid_course_entries): + """Test getting all courses""" + response = client.get("/courses", headers = {"Authorization": AUTH_TOKEN_TEACHER}) + assert response.status_code == 200 + data = [course["name"] for course in response.json["data"]] + assert all(course.name in data for course in valid_course_entries) + + def test_get_courses_wrong_parameter(self, client: FlaskClient): + """Test getting courses for a wrong parameter""" + response = client.get( + "/courses?parameter=0", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 400 + + def test_get_courses_wrong_name(self, client: FlaskClient): + """Test getting courses for a wrong course name""" + response = client.get( + "/courses?name=no_name", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert response.json["data"] == [] + + def test_get_courses_name(self, client: FlaskClient, valid_course_entry): + """Test getting courses for a given course name""" + response = client.get( + f"/courses?name={valid_course_entry.name}", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + + def test_get_courses_wrong_ufora_id(self, client: FlaskClient): + """Test getting courses for a wrong ufora_id""" + response = client.get( + "/courses?ufora_id=no_ufora_id", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert response.json["data"] == [] + + def test_get_courses_ufora_id(self, client: FlaskClient, valid_course_entry): + """Test getting courses for a given ufora_id""" + response = client.get( + f"/courses?ufora_id={valid_course_entry.ufora_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert valid_course_entry.ufora_id in \ + [course["ufora_id"] for course in response.json["data"]] + + def test_get_courses_wrong_teacher(self, client: FlaskClient): + """Test getting courses for a wrong teacher""" + response = client.get( + "/courses?teacher=no_teacher", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert response.json["data"] == [] + + def test_get_courses_teacher(self, client: FlaskClient, valid_course_entry): + """Test getting courses for a given teacher""" + response = client.get( + f"/courses?teacher={valid_course_entry.teacher}", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert valid_course_entry.teacher in [course["teacher"] for course in response.json["data"]] + + def test_get_courses_name_ufora_id(self, client: FlaskClient, valid_course_entry): + """Test getting courses for a given course name and ufora_id""" + response = client.get( + f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + + def test_get_courses_name_teacher(self, client: FlaskClient, valid_course_entry): + """Test getting courses for a given course name and teacher""" + response = client.get( + f"/courses?name={valid_course_entry.name}&teacher={valid_course_entry.teacher}", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + + def test_get_courses_ufora_id_teacher(self, client: FlaskClient, valid_course_entry): + """Test getting courses for a given ufora_id and teacher""" + response = client.get( + f"/courses?ufora_id={valid_course_entry.ufora_id}&teacher={valid_course_entry.teacher}", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + + def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, valid_course_entry): + """Test getting courses for a given name, ufora_id and teacher""" + response = client.get( + f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}" \ + f"&teacher={valid_course_entry.teacher}", + headers = {"Authorization": AUTH_TOKEN_TEACHER} + ) + assert response.status_code == 200 + assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + + ### POST COURSES ### + ### GET COURSE ### + ### PATCH COURSE ### + ### DELETE COURSE ### + ### GET COURSE ADMINS ### + ### POST COURSE ADMINS ### + ### DELETE COURSE ADMINS ### + ### GET COURSE STUDENTS ### + ### POST COURSE STUDENTS ### + ### DELETE COURSE STUDENTS ### def test_post_courses(self, client, valid_course): """ From d624566f691da5940d06c413897fff6712dee864 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:11:37 +0100 Subject: [PATCH 213/377] Add post courses tests --- .../tests/endpoints/course/courses_test.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 895168a6..6d3f9f1d 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -4,6 +4,7 @@ AUTH_TOKEN_BAD = "" AUTH_TOKEN_TEACHER = "teacher2" +AUTH_TOKEN_STUDENT = "student1" class TestCourseEndpoint: """Class to test the courses API endpoint""" @@ -127,6 +128,73 @@ def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, valid_cour assert valid_course_entry.name in [course["name"] for course in response.json["data"]] ### POST COURSES ### + def test_post_courses_not_authenticated(self, client: FlaskClient): + """Test posting a course when not authenticated""" + response = client.post("/courses") + assert response.status_code == 401 + + def test_post_courses_bad_authentication_token(self, client: FlaskClient): + """Test posting a course when given a bad authentication token""" + response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_BAD}) + assert response.status_code == 401 + + def test_post_courses_no_authorization(self, client: FlaskClient): + """Test posting a course when not having the correct authorization""" + response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_STUDENT}) + assert response.status_code == 403 + + def test_post_courses_wrong_name_type(self, client: FlaskClient): + """Test posting a course where the name does not have the correct type""" + response = client.post( + "/courses", + headers = {"Authorization": AUTH_TOKEN_TEACHER}, + json = { + "name": 0, + "ufora_id": "test" + } + ) + assert response.status_code == 400 + + def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): + """Test posting a course where the ufora_id does not have the correct type""" + response = client.post( + "/courses", + headers = {"Authorization": AUTH_TOKEN_TEACHER}, + json = { + "name": "test", + "ufora_id": 0 + } + ) + assert response.status_code == 400 + + def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_entry): + """Test posting a course where a field that doesn't occur in the model is given""" + response = client.post( + "/courses", + headers = {"Authorization": AUTH_TOKEN_TEACHER}, + json = { + "name": "test", + "ufora_id": "test", + "teacher": valid_teacher_entry.uid + } + ) + assert response.status_code == 400 + + def test_post_courses_correct(self, client: FlaskClient): + """Test posting a course""" + response = client.post( + "/courses", + headers = {"Authorization": AUTH_TOKEN_TEACHER}, + json = { + "name": "test", + "ufora_id": "test" + } + ) + assert response.status_code == 201 + response = client.get("/courses?name=test", headers = {"Authorization": AUTH_TOKEN_TEACHER}) + assert response.status_code == 200 + assert response.json["data"][0]["ufora_id"] == "test" + ### GET COURSE ### ### PATCH COURSE ### ### DELETE COURSE ### From c8dd663485277bb6e6649d2d3acbf021a49234dd Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:43:14 +0100 Subject: [PATCH 214/377] Add get course tests --- .../tests/endpoints/course/courses_test.py | 63 ++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 6d3f9f1d..b736f1e0 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -22,7 +22,7 @@ def test_get_courses_bad_authentication_token(self, client: FlaskClient): def test_get_courses_all(self, client: FlaskClient, valid_course_entries): """Test getting all courses""" - response = client.get("/courses", headers = {"Authorization": AUTH_TOKEN_TEACHER}) + response = client.get("/courses", headers = {"Authorization": AUTH_TOKEN_STUDENT}) assert response.status_code == 200 data = [course["name"] for course in response.json["data"]] assert all(course.name in data for course in valid_course_entries) @@ -31,7 +31,7 @@ def test_get_courses_wrong_parameter(self, client: FlaskClient): """Test getting courses for a wrong parameter""" response = client.get( "/courses?parameter=0", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 400 @@ -39,7 +39,7 @@ def test_get_courses_wrong_name(self, client: FlaskClient): """Test getting courses for a wrong course name""" response = client.get( "/courses?name=no_name", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert response.json["data"] == [] @@ -48,7 +48,7 @@ def test_get_courses_name(self, client: FlaskClient, valid_course_entry): """Test getting courses for a given course name""" response = client.get( f"/courses?name={valid_course_entry.name}", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] @@ -57,7 +57,7 @@ def test_get_courses_wrong_ufora_id(self, client: FlaskClient): """Test getting courses for a wrong ufora_id""" response = client.get( "/courses?ufora_id=no_ufora_id", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert response.json["data"] == [] @@ -66,7 +66,7 @@ def test_get_courses_ufora_id(self, client: FlaskClient, valid_course_entry): """Test getting courses for a given ufora_id""" response = client.get( f"/courses?ufora_id={valid_course_entry.ufora_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert valid_course_entry.ufora_id in \ @@ -76,7 +76,7 @@ def test_get_courses_wrong_teacher(self, client: FlaskClient): """Test getting courses for a wrong teacher""" response = client.get( "/courses?teacher=no_teacher", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert response.json["data"] == [] @@ -85,7 +85,7 @@ def test_get_courses_teacher(self, client: FlaskClient, valid_course_entry): """Test getting courses for a given teacher""" response = client.get( f"/courses?teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert valid_course_entry.teacher in [course["teacher"] for course in response.json["data"]] @@ -94,7 +94,7 @@ def test_get_courses_name_ufora_id(self, client: FlaskClient, valid_course_entry """Test getting courses for a given course name and ufora_id""" response = client.get( f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] @@ -103,7 +103,7 @@ def test_get_courses_name_teacher(self, client: FlaskClient, valid_course_entry) """Test getting courses for a given course name and teacher""" response = client.get( f"/courses?name={valid_course_entry.name}&teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] @@ -112,7 +112,7 @@ def test_get_courses_ufora_id_teacher(self, client: FlaskClient, valid_course_en """Test getting courses for a given ufora_id and teacher""" response = client.get( f"/courses?ufora_id={valid_course_entry.ufora_id}&teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] @@ -122,7 +122,7 @@ def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, valid_cour response = client.get( f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}" \ f"&teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_TEACHER} + headers = {"Authorization": AUTH_TOKEN_STUDENT} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] @@ -180,7 +180,7 @@ def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_e ) assert response.status_code == 400 - def test_post_courses_correct(self, client: FlaskClient): + def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): """Test posting a course""" response = client.post( "/courses", @@ -193,9 +193,44 @@ def test_post_courses_correct(self, client: FlaskClient): assert response.status_code == 201 response = client.get("/courses?name=test", headers = {"Authorization": AUTH_TOKEN_TEACHER}) assert response.status_code == 200 - assert response.json["data"][0]["ufora_id"] == "test" + data = response.json["data"] + assert data[0]["ufora_id"] == "test" + assert data[0]["teacher"] == valid_teacher_entry.uid # uid corresponds with AUTH_TOKEN ### GET COURSE ### + def test_get_course_not_authenticated(self, client: FlaskClient, valid_course_entry): + """Test getting a course while not authenticated""" + response = client.get(f"/courses/{valid_course_entry.course_id}") + assert response.status_code == 401 + + def test_get_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): + """Test getting a course while using a bad authentication token""" + response = client.get( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_BAD} + ) + assert response.status_code == 401 + + def test_get_course_wrong_course_id(self, client: FlaskClient): + """Test getting a non existing course by given a wrong course_id""" + response = client.get( + "/courses/0", + headers = {"Authorization": AUTH_TOKEN_STUDENT} + ) + assert response.status_code == 404 + + def test_get_course_correct(self, client: FlaskClient, valid_course_entry): + """Test getting a course""" + response = client.get( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_STUDENT} + ) + assert response.status_code == 200 + data = response.json["data"] + assert data["name"] == valid_course_entry.name + assert data["ufora_id"] == valid_course_entry.ufora_id + assert data["teacher"] == valid_course_entry.teacher + ### PATCH COURSE ### ### DELETE COURSE ### ### GET COURSE ADMINS ### From 181936abf6364919b0a437ae44b597fda70aafad Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:23:45 +0100 Subject: [PATCH 215/377] Add patch course tests --- .../tests/endpoints/course/courses_test.py | 108 +++++++++++++++++- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index b736f1e0..42f5824d 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -3,7 +3,8 @@ from flask.testing import FlaskClient AUTH_TOKEN_BAD = "" -AUTH_TOKEN_TEACHER = "teacher2" +AUTH_TOKEN_TEACHER_1 = "teacher1" +AUTH_TOKEN_TEACHER_2 = "teacher2" AUTH_TOKEN_STUDENT = "student1" class TestCourseEndpoint: @@ -147,7 +148,7 @@ def test_post_courses_wrong_name_type(self, client: FlaskClient): """Test posting a course where the name does not have the correct type""" response = client.post( "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER}, + headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, json = { "name": 0, "ufora_id": "test" @@ -159,7 +160,7 @@ def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): """Test posting a course where the ufora_id does not have the correct type""" response = client.post( "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER}, + headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, json = { "name": "test", "ufora_id": 0 @@ -171,7 +172,7 @@ def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_e """Test posting a course where a field that doesn't occur in the model is given""" response = client.post( "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER}, + headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, json = { "name": "test", "ufora_id": "test", @@ -184,14 +185,17 @@ def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): """Test posting a course""" response = client.post( "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER}, + headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, json = { "name": "test", "ufora_id": "test" } ) assert response.status_code == 201 - response = client.get("/courses?name=test", headers = {"Authorization": AUTH_TOKEN_TEACHER}) + response = client.get( + "/courses?name=test", + headers = {"Authorization": AUTH_TOKEN_STUDENT} + ) assert response.status_code == 200 data = response.json["data"] assert data[0]["ufora_id"] == "test" @@ -232,6 +236,98 @@ def test_get_course_correct(self, client: FlaskClient, valid_course_entry): assert data["teacher"] == valid_course_entry.teacher ### PATCH COURSE ### + def test_patch_course_not_authenticated(self, client: FlaskClient, valid_course_entry): + """Test patching a course while not authenticated""" + response = client.patch(f"/courses/{valid_course_entry.course_id}") + assert response.status_code == 401 + + def test_patch_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): + """Test patching a course while using a bad authentication token""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_BAD} + ) + assert response.status_code == 401 + + def test_patch_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): + """Test patching a course as a student""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_STUDENT} + ) + assert response.status_code == 403 + + def test_patch_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): + """Test patching a course as a teacher of a different course""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_1} + ) + assert response.status_code == 403 + + def test_patch_course_wrong_course_id(self, client: FlaskClient, valid_course_entry): + """Test patching a course that does not exist""" + response = client.patch( + "/courses/0", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + ) + assert response.status_code == 404 + + def test_patch_course_wrong_name_type(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a wrong type for the course name""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + json = {"name": 0} + ) + assert response.status_code == 400 + + def test_patch_course_ufora_id_type(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a wrong type for the ufora_id""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + json = {"ufora_id": 0} + ) + assert response.status_code == 400 + + def test_patch_course_wrong_teacher_type(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a wrong type for the teacher""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + json = {"teacher": 0} + ) + assert response.status_code == 400 + + def test_patch_course_wrong_teacher(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a teacher that does not exist""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + json = {"teacher": "no_teacher"} + ) + assert response.status_code == 400 + + def test_patch_course_incorrect_field(self, client: FlaskClient, valid_course_entry): + """Test patching a course with a field that doesn't occur in the course model""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + json = {"field": 0} + ) + assert response.status_code == 400 + + def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): + """Test patching a course""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + json = {"name": "test"} + ) + assert response.status_code == 200 + assert response.json["data"]["name"] == "test" + ### DELETE COURSE ### ### GET COURSE ADMINS ### ### POST COURSE ADMINS ### From 067fe793d36efd0d059a23a4ef97fdfb6ff49ea8 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 22 Mar 2024 15:31:43 +0100 Subject: [PATCH 216/377] Add delete course tests --- .../tests/endpoints/course/courses_test.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 42f5824d..5e283ebc 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -329,6 +329,56 @@ def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): assert response.json["data"]["name"] == "test" ### DELETE COURSE ### + def test_delete_course_not_authenticated(self, client: FlaskClient, valid_course_entry): + """Test deleting a course while not authenticated""" + response = client.delete(f"/courses/{valid_course_entry.course_id}") + assert response.status_code == 401 + + def test_delete_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): + """Test deleting a course while using a bad authentication token""" + response = client.delete( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_BAD} + ) + assert response.status_code == 401 + + def test_delete_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): + """Test deleting a course as a student""" + response = client.delete( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_STUDENT} + ) + assert response.status_code == 403 + + def test_delete_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): + """Test deleting a course as a teacher of a different course""" + response = client.delete( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_1} + ) + assert response.status_code == 403 + + def test_delete_course_wrong_course_id(self, client: FlaskClient): + """Test deleting a course that does not exist""" + response = client.delete( + "/courses/0", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + ) + assert response.status_code == 404 + + def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): + """Test deleting a course""" + response = client.delete( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + ) + assert response.status_code == 200 + response = client.get( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + ) + assert response.status_code == 404 + ### GET COURSE ADMINS ### ### POST COURSE ADMINS ### ### DELETE COURSE ADMINS ### From bcee3bcb74715541e17b18bf9713e4dd38abeede Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sun, 24 Mar 2024 17:33:29 +0100 Subject: [PATCH 217/377] Removes all usage of str(e) in courses endpoint (#126) * Removed all usage of str(e) in courses endpoint * common parts of error put into const var --------- Co-authored-by: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> --- .../endpoints/courses/courses_utils.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index cb36c6a4..4c01ee73 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -17,7 +17,8 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(f"{API_URL}/", "courses") +RESPONSE_URL = urljoin(API_URL + "/", "courses") +BASE_DB_ERROR = "Database error occurred while" def execute_query_abort_if_db_error(query, url, query_all=False): """ @@ -35,8 +36,8 @@ def execute_query_abort_if_db_error(query, url, query_all=False): result = query.all() else: result = query.first() - except SQLAlchemyError as e: - response = json_message(str(e)) + except SQLAlchemyError: + response = json_message(f"{BASE_DB_ERROR} executing query") response["url"] = url abort(500, description=response) return result @@ -52,9 +53,9 @@ def add_abort_if_error(to_add, url): """ try: db.session.add(to_add) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} adding object") response["url"] = url abort(500, description=response) @@ -69,9 +70,9 @@ def delete_abort_if_error(to_delete, url): """ try: db.session.delete(to_delete) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} deleting object") response["url"] = url abort(500, description=response) @@ -82,9 +83,9 @@ def commit_abort_if_error(url): """ try: db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} committing changes") response["url"] = url abort(500, description=response) From e770091c8164c1f3ec7726ddc09e5736622255cc Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:48:11 +0100 Subject: [PATCH 218/377] changed port to 5001 (#142) --- backend/test_auth_server/__main__.py | 2 +- backend/tests.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index 5d13b637..bf5fd576 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -67,4 +67,4 @@ def get(self): app = Flask(__name__) app.register_blueprint(index_bp) -app.run(debug=True, host='0.0.0.0') +app.run(debug=True, host='0.0.0.0', port=5001) diff --git a/backend/tests.yaml b/backend/tests.yaml index 6238d2ec..397702d8 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -41,7 +41,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here - AUTHENTICATION_URL: http://auth-server:5000 # Use the service name defined in Docker Compose + AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments volumes: - .:/app From 71eb3d47b09ed3d122f2f9fe970fc0073120b5b3 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:51:56 +0100 Subject: [PATCH 219/377] Removed is_teacher and is_admin, changed to role enum (#127) * Removed is_teacher and is_admin, changed to role enum * sql enum type * usage of enum and fixed sql * made role serializable * clean * docs * fixed user patch * args vs json * test --- backend/db_construct.sql | 5 +- .../endpoints/index/OpenAPI_Object.json | 36 +++----- backend/project/endpoints/users.py | 45 +++++----- backend/project/models/user.py | 23 +++-- backend/project/utils/authentication.py | 87 +++++++++++-------- backend/project/utils/models/user_utils.py | 6 +- backend/tests/conftest.py | 10 +-- backend/tests/endpoints/conftest.py | 21 +++-- backend/tests/endpoints/user_test.py | 53 ++++++----- backend/tests/models/user_test.py | 10 +-- 10 files changed, 158 insertions(+), 138 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index bb7c7eb7..e3f6af41 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,9 +1,10 @@ +CREATE TYPE role AS ENUM ('STUDENT', 'TEACHER', 'ADMIN'); + CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); CREATE TABLE users ( uid VARCHAR(255), - is_teacher BOOLEAN, - is_admin BOOLEAN, + role role NOT NULL, PRIMARY KEY(uid) ); diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/endpoints/index/OpenAPI_Object.json index 829f7c38..5ac3ef53 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/endpoints/index/OpenAPI_Object.json @@ -1374,14 +1374,11 @@ "uid": { "type": "string" }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "enum" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": ["uid", "role"] } } } @@ -1399,14 +1396,11 @@ "uid": { "type": "string" }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "enum" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": ["uid", "role"] } } } @@ -1451,14 +1445,11 @@ "uid": { "type": "string" }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "enum" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": ["uid", "role"] } } } @@ -1487,14 +1478,11 @@ "schema": { "type": "object", "properties": { - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" + "role": { + "type": "role" } }, - "required": ["is_teacher", "is_admin"] + "required": ["role"] } } } diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 7d073c6c..34e65817 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.user import User as userModel +from project.models.user import User as userModel, Role from project.utils.authentication import login_required, authorize_user, \ authorize_admin, not_allowed @@ -29,16 +29,13 @@ def get(self): """ try: query = userModel.query - is_teacher = request.args.get('is_teacher') - is_admin = request.args.get('is_admin') - - if is_teacher is not None: - query = query.filter(userModel.is_teacher == (is_teacher.lower() == 'true')) - - if is_admin is not None: - query = query.filter(userModel.is_admin == (is_admin.lower() == 'true')) + role = request.args.get("role") + if role is not None: + role = Role[role.upper()] + query = query.filter(userModel.role == role) users = query.all() + users = [user.to_dict() for user in users] result = jsonify({"message": "Queried all users", "data": users, "url":f"{API_URL}/users", "status_code": 200}) @@ -54,26 +51,25 @@ def post(self): It should create a new user and return a success message. """ uid = request.json.get('uid') - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') + role = request.json.get("role") + role = Role[role.upper()] if role is not None else None url = f"{API_URL}/users" - if is_teacher is None or is_admin is None or uid is None: + if role is None or uid is None: return { "message": "Invalid request data!", "correct_format": { "uid": "User ID (string)", - "is_teacher": "Teacher status (boolean)", - "is_admin": "Admin status (boolean)" - },"url": url + "role": "User role (string)" + },"url": f"{API_URL}/users" }, 400 try: user = db.session.get(userModel, uid) if user is not None: # Bad request, error code could be 409 but is rarely used return {"message": f"User {uid} already exists"}, 400 - # Code to create a new user in the database using the uid, is_teacher, and is_admin - new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) + # Code to create a new user in the database using the uid and role + new_user = userModel(uid=uid, role=role) db.session.add(new_user) db.session.commit() return jsonify({"message": "User created successfully!", @@ -99,7 +95,7 @@ def get(self, user_id): if user is None: return {"message": "User not found!","url": f"{API_URL}/users"}, 404 - return jsonify({"message": "User queried","data":user, + return jsonify({"message": "User queried","data":user.to_dict(), "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: return {"message": "An error occurred while fetching the user", @@ -114,22 +110,21 @@ def patch(self, user_id): dict: A dictionary containing the message indicating the success or failure of the update. """ - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') + role = request.json.get("role") + role = Role[role.upper()] if role is not None else None try: user = db.session.get(userModel, user_id) if user is None: return {"message": "User not found!","url": f"{API_URL}/users"}, 404 - if is_teacher is not None: - user.is_teacher = is_teacher - if is_admin is not None: - user.is_admin = is_admin + if role is not None: + user.role = role # Save the changes to the database db.session.commit() return jsonify({"message": "User updated successfully!", - "data": user, "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) + "data": user.to_dict(), + "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() diff --git a/backend/project/models/user.py b/backend/project/models/user.py index bb130349..7cd59fd1 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -1,17 +1,30 @@ """User model""" +from enum import Enum from dataclasses import dataclass -from sqlalchemy import Boolean, Column, String +from sqlalchemy import Column, String, Enum as EnumField from project.db_in import db +class Role(Enum): + """This class defines the roles of a user""" + STUDENT = 0 + TEACHER = 1 + ADMIN = 2 + @dataclass class User(db.Model): """This class defines the users table, - a user has a uid, - is_teacher and is_admin booleans because a user + a user has a uid and a role, a user can be either a student,admin or teacher""" __tablename__ = "users" uid: str = Column(String(255), primary_key=True) - is_teacher: bool = Column(Boolean) - is_admin: bool = Column(Boolean) + role: Role = Column(EnumField(Role), nullable=False) + def to_dict(self): + """ + Converts a User to a serializable dict + """ + return { + 'uid': self.uid, + 'role': self.role.name, # Convert the enum to a string + } diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index 61b64e61..c1a96248 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -13,7 +13,7 @@ from project import db -from project.models.user import User +from project.models.user import User, Role from project.utils.models.course_utils import is_admin_of_course, \ is_student_of_course, is_teacher_of_course from project.utils.models.project_utils import get_course_of_project, project_visible @@ -29,7 +29,7 @@ def not_allowed(f): """Decorator function to immediately abort the current request and return 403: Forbidden""" @wraps(f) def wrap(*args, **kwargs): - return {"message":"Forbidden action"}, 403 + return {"message": "Forbidden action"}, 403 return wrap @@ -39,20 +39,23 @@ def return_authenticated_user_id(): """ authentication = request.headers.get("Authorization") if not authentication: - abort(make_response(({"message": - "No authorization given, you need an access token to use this API"} - , 401))) + abort( + make_response(( + {"message": + "No authorization given, you need an access token to use this API"}, + 401))) auth_header = {"Authorization": authentication} try: - response = requests.get(AUTHENTICATION_URL, headers=auth_header, timeout=5) + response = requests.get( + AUTHENTICATION_URL, headers=auth_header, timeout=5) except TimeoutError: - abort(make_response(({"message":"Request to Microsoft timed out"} - , 500))) + abort(make_response( + ({"message": "Request to Microsoft timed out"}, 500))) if not response or response.status_code != 200: abort(make_response(({"message": - "An error occured while trying to authenticate your access token"} - , 401))) + "An error occured while trying to authenticate your access token"}, + 401))) user_info = response.json() auth_user_id = user_info["id"] @@ -61,26 +64,30 @@ def return_authenticated_user_id(): except SQLAlchemyError: db.session.rollback() abort(make_response(({"message": - "An unexpected database error occured while fetching the user"}, 500))) + "An unexpected database error occured while fetching the user"}, + 500))) if user: return auth_user_id - is_teacher = False + + # Use the Enum here + role = Role.STUDENT if user_info["jobTitle"] is not None: - is_teacher = True + role = Role.TEACHER # add user if not yet in database try: - new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) + new_user = User(uid=auth_user_id, role=role) db.session.add(new_user) db.session.commit() except SQLAlchemyError: db.session.rollback() abort(make_response(({"message": - """An unexpected database error occured + """An unexpected database error occured while creating the user during authentication"""}, 500))) return auth_user_id + def login_required(f): """ This function will check if the person sending a request to the API is logged in @@ -104,7 +111,7 @@ def wrap(*args, **kwargs): if is_admin(auth_user_id): return f(*args, **kwargs) abort(make_response(({"message": - """You are not authorized to perfom this action, + """You are not authorized to perfom this action, only admins are authorized"""}, 403))) return wrap @@ -121,7 +128,7 @@ def wrap(*args, **kwargs): kwargs["teacher_id"] = auth_user_id return f(*args, **kwargs) abort(make_response(({"message": - """You are not authorized to perfom this action, + """You are not authorized to perfom this action, only teachers are authorized"""}, 403))) return wrap @@ -138,7 +145,8 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, kwargs["course_id"]): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -153,10 +161,10 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = kwargs["course_id"] if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, only teachers and course admins are authorized"""}, 403))) return wrap @@ -174,7 +182,7 @@ def wrap(*args, **kwargs): if auth_user_id == user_id: return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not this user"""}, 403))) return wrap @@ -194,7 +202,7 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, course_id): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not the teacher of this project"""}, 403))) return wrap @@ -211,9 +219,9 @@ def wrap(*args, **kwargs): project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not the teacher or an admin of this project"""}, 403))) return wrap @@ -232,13 +240,15 @@ def wrap(*args, **kwargs): project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap + def authorize_submissions_request(f): """This function will check if the person sending a request to the API is logged in, and either the teacher/admin of the course or the student @@ -251,14 +261,15 @@ def wrap(*args, **kwargs): course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if (is_student_of_course(auth_user_id, course_id) and project_visible(project_id) - and auth_user_id == request.args.get("uid")): + and auth_user_id == request.args.get("uid")): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -273,9 +284,10 @@ def wrap(*args, **kwargs): course_id = get_course_of_project(project_id) if (is_student_of_course(auth_user_id, course_id) and project_visible(project_id) - and auth_user_id == request.form.get("uid")): + and auth_user_id == request.form.get("uid")): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -290,7 +302,8 @@ def wrap(*args, **kwargs): submission = get_submission(submission_id) if submission.uid == auth_user_id: return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -303,9 +316,10 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = get_course_of_submission(kwargs["submission_id"]) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -323,9 +337,8 @@ def wrap(*args, **kwargs): return f(*args, **kwargs) course_id = get_course_of_project(submission.project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) abort(make_response(({"message": - "You're not authorized to perform this action"} - , 403))) + "You're not authorized to perform this action"}, 403))) return wrap diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py index f601c8b3..37cd263c 100644 --- a/backend/project/utils/models/user_utils.py +++ b/backend/project/utils/models/user_utils.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.user import User +from project.models.user import User, Role load_dotenv() API_URL = getenv("API_HOST") @@ -28,9 +28,9 @@ def get_user(user_id): def is_teacher(auth_user_id): """This function checks whether the user with auth_user_id is a teacher""" user = get_user(auth_user_id) - return user.is_teacher + return user.role == Role.TEACHER def is_admin(auth_user_id): """This function checks whether the user with auth_user_id is a teacher""" user = get_user(auth_user_id) - return user.is_admin + return user.role == Role.ADMIN diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc605602..fe9d3961 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,7 +6,7 @@ from project.sessionmaker import engine, Session from project.db_in import db from project.models.course import Course -from project.models.user import User +from project.models.user import User,Role from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission, SubmissionStatus @@ -34,10 +34,10 @@ def db_session(): def users(): """Return a list of users to populate the database""" return [ - User(uid="brinkmann", is_admin=True, is_teacher=True), - User(uid="laermans", is_admin=True, is_teacher=True), - User(uid="student01", is_admin=False, is_teacher=False), - User(uid="student02", is_admin=False, is_teacher=False) + User(uid="brinkmann", role=Role.ADMIN), + User(uid="laermans", role=Role.ADMIN), + User(uid="student01", role=Role.STUDENT), + User(uid="student02", role=Role.STUDENT) ] def courses(): diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 3f234b62..de74e6ab 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -7,7 +7,7 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.exc import SQLAlchemyError -from project.models.user import User +from project.models.user import User,Role from project.models.course import Course from project.models.course_share_code import CourseShareCode from project import create_app_with_db @@ -47,7 +47,7 @@ def valid_user(): """ return { "uid": "w_student", - "is_teacher": False + "role": Role.STUDENT.name } @pytest.fixture @@ -67,8 +67,7 @@ def valid_admin(): """ return { "uid": "admin_person", - "is_teacher": False, - "is_admin":True + "role": Role.ADMIN, } @pytest.fixture @@ -95,10 +94,10 @@ def valid_user_entries(session): Returns a list of users that are in the database """ users = [ - User(uid="del", is_admin=False, is_teacher=True), - User(uid="pat", is_admin=False, is_teacher=True), - User(uid="u_get", is_admin=False, is_teacher=True), - User(uid="query_user", is_admin=True, is_teacher=False)] + User(uid="del", role=Role.TEACHER), + User(uid="pat", role=Role.TEACHER), + User(uid="u_get", role=Role.TEACHER), + User(uid="query_user", role=Role.ADMIN)] session.add_all(users) session.commit() @@ -149,7 +148,7 @@ def app(): @pytest.fixture def course_teacher_ad(): """A user that's a teacher for testing""" - ad_teacher = User(uid="Gunnar", is_teacher=True, is_admin=True) + ad_teacher = User(uid="Gunnar", role=Role.TEACHER) return ad_teacher @pytest.fixture @@ -199,7 +198,7 @@ def client(app): @pytest.fixture def valid_teacher_entry(session): """A valid teacher for testing that's already in the db""" - teacher = User(uid="Bart", is_teacher=True, is_admin=False) + teacher = User(uid="Bart", role=Role.TEACHER) try: session.add(teacher) session.commit() @@ -229,7 +228,7 @@ def valid_course_entry(session, valid_course): def valid_students_entries(session): """Valid students for testing that are already in the db""" students = [ - User(uid=f"student_sel2_{i}", is_teacher=False) + User(uid=f"student_sel2_{i}", role=Role.STUDENT) for i in range(3) ] session.add_all(students) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c6044db2..7d3a0c39 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -11,7 +11,7 @@ import pytest from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine -from project.models.user import User +from project.models.user import User,Role from project.db_in import db from tests import db_url @@ -24,12 +24,12 @@ def user_db_session(): db.metadata.create_all(engine) session = Session() session.add_all( - [User(uid="del", is_admin=False, is_teacher=True), - User(uid="pat", is_admin=False, is_teacher=True), - User(uid="u_get", is_admin=False, is_teacher=True), - User(uid="query_user", is_admin=True, is_teacher=False) - ] - ) + [User(uid="del", role=Role.TEACHER), + User(uid="pat", role=Role.TEACHER), + User(uid="u_get", role=Role.TEACHER), + User(uid="query_user", role=Role.ADMIN) + ] + ) session.commit() yield session session.rollback() @@ -120,38 +120,50 @@ def test_get_one_user_wrong_authentication(self, client, valid_user_entry): assert response.status_code == 401 def test_patch_user_not_authorized(self, client, valid_admin_entry, valid_user_entry): - """Test trying to patch a user without authorization""" - new_is_teacher = not valid_user_entry.is_teacher + """Test updating a user.""" + if valid_user_entry.role == Role.TEACHER: + new_role = Role.ADMIN + if valid_user_entry.role == Role.ADMIN: + new_role = Role.STUDENT + else: + new_role = Role.TEACHER + new_role = new_role.name response = client.patch(f"/users/{valid_user_entry.uid}", json={ - 'is_teacher': new_is_teacher, - 'is_admin': not valid_user_entry.is_admin + 'role': new_role }, headers={"Authorization":"student01"}) assert response.status_code == 403 # Patching a user is not allowed as a not-admin def test_patch_user(self, client, valid_admin_entry, valid_user_entry): """Test updating a user.""" - new_is_teacher = not valid_user_entry.is_teacher - + if valid_user_entry.role == Role.TEACHER: + new_role = Role.ADMIN + if valid_user_entry.role == Role.ADMIN: + new_role = Role.STUDENT + else: + new_role = Role.TEACHER + new_role = new_role.name response = client.patch(f"/users/{valid_user_entry.uid}", json={ - 'is_teacher': new_is_teacher, - 'is_admin': not valid_user_entry.is_admin + 'role': new_role }, headers={"Authorization":"admin1"}) assert response.status_code == 200 def test_patch_non_existent(self, client, valid_admin_entry): """Test updating a non-existent user.""" response = client.patch("/users/-20", json={ - 'is_teacher': False, - 'is_admin': True + 'role': Role.TEACHER.name }, headers={"Authorization":"admin1"}) assert response.status_code == 404 def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) - valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] + if valid_user_form["role"] == Role.TEACHER.name: + valid_user_form["role"] = Role.STUDENT.name + else: + valid_user_form["role"] = Role.TEACHER.name + response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form, headers={"Authorization":"admin1"}) assert response.status_code == 415 @@ -159,12 +171,11 @@ def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false", + response = client.get("/users?role=ADMIN", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query users = response.json["data"] for user in users: - assert user["is_admin"] is True - assert user["is_teacher"] is False + assert Role[user["role"]] == Role.ADMIN diff --git a/backend/tests/models/user_test.py b/backend/tests/models/user_test.py index 8a026711..05520b8c 100644 --- a/backend/tests/models/user_test.py +++ b/backend/tests/models/user_test.py @@ -3,14 +3,14 @@ from pytest import raises, mark from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError -from project.models.user import User +from project.models.user import User,Role class TestUserModel: """Class to test the User model""" def test_create_user(self, session: Session): """Test if a user can be created""" - user = User(uid="user01", is_teacher=False, is_admin=False) + user = User(uid="user01", role=Role.STUDENT) session.add(user) session.commit() assert session.get(User, "user01") is not None @@ -21,14 +21,14 @@ def test_query_user(self, session: Session): assert session.query(User).count() == 4 teacher = session.query(User).filter_by(uid="brinkmann").first() assert teacher is not None - assert teacher.is_teacher + assert teacher.role == Role.ADMIN def test_update_user(self, session: Session): """Test if a user can be updated""" student = session.query(User).filter_by(uid="student01").first() - student.is_admin = True + student.role = Role.ADMIN session.commit() - assert session.get(User, "student01").is_admin + assert session.get(User, "student01").role == Role.ADMIN def test_delete_user(self, session: Session): """Test if a user can be deleted""" From 7c897c115e69348ff78d6c9e51599fb8c918fcd2 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:52:40 +0100 Subject: [PATCH 220/377] check if course name is blank (#100) * check if course name is blank * generalized blank string check * fixed yo request * fixed conflicts * cleaned code * fix --- backend/project/utils/query_agent.py | 2 +- backend/tests/endpoints/conftest.py | 10 ++++++ .../tests/endpoints/course/courses_test.py | 34 ++++++++++++++----- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 745006a1..01368eb3 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -61,7 +61,7 @@ def create_model_instance(model: DeclarativeMeta, if required_fields is None: required_fields = [] # Check if all non-nullable fields are present in the data - missing_fields = [field for field in required_fields if field not in data] + missing_fields = [field for field in required_fields if field not in data or data[field] == ''] if missing_fields: return {"error": f"Missing required fields: {', '.join(missing_fields)}", diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index de74e6ab..8a1c53ab 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -216,6 +216,16 @@ def course_no_name(valid_teacher_entry): """A course with no name""" return {"name": "", "teacher": valid_teacher_entry.uid} +@pytest.fixture +def course_empty_name(): + """A course with an empty name""" + return {"name": "", "teacher": "Bart"} + +@pytest.fixture +def invalid_course(): + """An invalid course for testing.""" + return {"invalid": "error"} + @pytest.fixture def valid_course_entry(session, valid_course): """A valid course for testing that's already in the db""" diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 0249559a..b82d2728 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,14 +1,16 @@ """Here we will test all the courses endpoint related functionality""" + class TestCourseEndpoint: """Class for testing the courses endpoint""" - def test_post_courses(self, client, valid_course): + def test_post_courses(self, client, valid_course, invalid_course): """ Test posting a course to the /courses endpoint """ - response = client.post("/courses", json=valid_course, headers={"Authorization":"teacher2"}) + response = client.post("/courses", json=valid_course, + headers={"Authorization": "teacher2"}) assert response.status_code == 201 data = response.json assert data["data"]["name"] == "Sel" @@ -16,9 +18,23 @@ def test_post_courses(self, client, valid_course): # Is reachable using the API get_response = client.get(f"/courses/{data['data']['course_id']}", - headers={"Authorization":"teacher2"}) + headers={"Authorization": "teacher2"}) assert get_response.status_code == 200 + response = client.post( + "/courses?uid=Bart", json=invalid_course, + headers={"Authorization": "teacher2"} + ) # invalid course + assert response.status_code == 400 + + def test_post_no_name(self, client, course_empty_name): + """ + Test posting a course with a blank name + """ + + response = client.post("/courses?uid=Bart", json=course_empty_name, + headers={"Authorization": "teacher2"}) + assert response.status_code == 400 def test_post_courses_course_id_students_and_admins( self, client, valid_course_entry, valid_students_entries): @@ -33,18 +49,18 @@ def test_post_courses_course_id_students_and_admins( response = client.post( sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - json={"students": valid_students}, headers={"Authorization":"teacher2"} + json={"students": valid_students}, headers={"Authorization": "teacher2"} ) assert response.status_code == 403 - def test_get_courses(self, valid_course_entries, client): """ Test all the getters for the courses endpoint """ - response = client.get("/courses", headers={"Authorization":"teacher1"}) + response = client.get( + "/courses", headers={"Authorization": "teacher1"}) assert response.status_code == 200 data = response.json for course in valid_course_entries: @@ -54,13 +70,13 @@ def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" response = client.delete( - "/courses/" + str(valid_course_entry.course_id), headers={"Authorization":"teacher2"} + "/courses/" + str(valid_course_entry.course_id), headers={"Authorization": "teacher2"} ) assert response.status_code == 200 # Is not reachable using the API get_response = client.get(f"/courses/{valid_course_entry.course_id}", - headers={"Authorization":"teacher2"}) + headers={"Authorization": "teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): @@ -69,7 +85,7 @@ def test_course_patch(self, valid_course_entry, client): """ response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ "name": "TestTest" - }, headers={"Authorization":"teacher2"}) + }, headers={"Authorization": "teacher2"}) data = response.json assert response.status_code == 200 assert data["data"]["name"] == "TestTest" From 3336fd400655d0f5a632e6352c9d131c2fae646d Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Tue, 26 Mar 2024 20:54:00 +0100 Subject: [PATCH 221/377] started initial readme for repo (#122) * started initial readme for repo * added workflow badges * readme edits * spelling mistakes * grammar fix --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c684e92f..0c446923 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ -# UGent-3 \ No newline at end of file +# UGent-3 project peristerónas +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) +## Introduction +Project peristerónas was created to aid both teachers and students in achieving a +clear overview of deadlines and projects that need to be submitted. + +There's a separate functionality depending on if you're logged in as a teacher or as a student. +For students the main functionality is to have a user-friendly interface to submit projects and check the correctness of their submissions. + +When a teacher is logged in they can get an overview of the projects he assigned and check how many students have already +handed in a correct solution for example. It's also possible to edit the project and to grade projects in peristerónas' interface. +## Usage +### Frontend +For the developer instructions of the frontend please refer to the [frontend readme](frontend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. +### Backend +For the developer instructions of the backend please refer to the [backend readme](backend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. From 6aa0f0c96fc2d6b77137b6ae1ade48dd4a596b11 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Wed, 27 Mar 2024 12:54:41 +0100 Subject: [PATCH 222/377] fixed route /docs and edited projects endpoint (#123) * fixed route /docs and edited projects endpoint * linter+test succeed * fix: remove duplicate json file * edited env variable for failing tests * linter --- backend/project/__init__.py | 2 + .../project/endpoints/docs/docs_endpoint.py | 17 + backend/project/endpoints/index/index.py | 7 +- .../index => static}/OpenAPI_Object.json | 1662 ++++++++++------- backend/requirements.txt | 1 + backend/tests.yaml | 2 + 6 files changed, 973 insertions(+), 718 deletions(-) create mode 100644 backend/project/endpoints/docs/docs_endpoint.py rename backend/project/{endpoints/index => static}/OpenAPI_Object.json (66%) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 664ff947..9c71aafd 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -10,6 +10,7 @@ from .endpoints.projects.project_endpoint import project_bp from .endpoints.submissions import submissions_bp from .endpoints.courses.join_codes.join_codes_config import join_codes_bp +from .endpoints.docs.docs_endpoint import swagger_ui_blueprint def create_app(): """ @@ -25,6 +26,7 @@ def create_app(): app.register_blueprint(project_bp) app.register_blueprint(submissions_bp) app.register_blueprint(join_codes_bp) + app.register_blueprint(swagger_ui_blueprint) return app diff --git a/backend/project/endpoints/docs/docs_endpoint.py b/backend/project/endpoints/docs/docs_endpoint.py new file mode 100644 index 00000000..197641ae --- /dev/null +++ b/backend/project/endpoints/docs/docs_endpoint.py @@ -0,0 +1,17 @@ +""" +Module for defining the swagger docs +""" + +from os import getenv +from flask_swagger_ui import get_swaggerui_blueprint + +SWAGGER_URL = getenv("DOCS_URL") +API_URL = getenv("DOCS_JSON_PATH") + +swagger_ui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + f"/{API_URL}", + config={ + 'app_name': 'Pigeonhole API' + } +) diff --git a/backend/project/endpoints/index/index.py b/backend/project/endpoints/index/index.py index 1bfe67cb..4feb3382 100644 --- a/backend/project/endpoints/index/index.py +++ b/backend/project/endpoints/index/index.py @@ -1,11 +1,13 @@ """Index api point""" import os -from flask import Blueprint, send_from_directory +from flask import Blueprint, send_file from flask_restful import Resource, Api index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) +API_URL = os.getenv("DOCS_JSON_PATH") + class Index(Resource): """Api endpoint for the / route""" @@ -14,8 +16,7 @@ def get(self): Example of an api endpoint function that will respond to get requests made to return a json data structure with key Message and value Hello World! """ - dir_path = os.path.dirname(os.path.realpath(__file__)) - return send_from_directory(dir_path, "OpenAPI_Object.json") + return send_file(API_URL) index_bp.add_url_rule("/", view_func=Index.as_view("index")) diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/static/OpenAPI_Object.json similarity index 66% rename from backend/project/endpoints/index/OpenAPI_Object.json rename to backend/project/static/OpenAPI_Object.json index 5ac3ef53..ba0b5381 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/static/OpenAPI_Object.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.0", + "openapi": "3.0.1", "info": { "title": "Pigeonhole API", "summary": "A project submission and grading API for University Ghent students and professors.", @@ -56,7 +56,7 @@ "type": "object", "properties": { "project_id": { - "type": "int" + "type": "integer" }, "description": { "type": "string" @@ -74,6 +74,27 @@ }, "post": { "description": "Upload a new project", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + }, + "required": ["assignment_file", "title", "description", "course_id", "visible_for_students", "archived"] + } + } + } + }, "responses": { "201": { "description": "Uploaded a new project succesfully", @@ -82,7 +103,51 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { + "type": "string" + }, + "data": { + "type": "object" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad formatted request for uploading a project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong inserting model into the database", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error":{ + "type": "string" + }, + "url": { + "type": "string" + } } } } @@ -94,6 +159,17 @@ "/projects/{id}": { "get": { "description": "Return a project with corresponding id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "A project with corresponding id", @@ -102,44 +178,39 @@ "schema": { "type": "object", "properties": { - "archived": { - "type": "bool" + "project_id": { + "type": "integer" }, - "assignment_file": { + "title": { "type": "string" }, - "course_id": { - "type": "int" + "description": { + "type": "string" }, - "deadline": { - "type": "date" + "assignment_file": { + "type": "string", + "format": "binary" }, - "description": { - "type": "array", - "items": { - "description": "string" - } + "deadline": { + "type": "string" }, - "project_id": { - "type": "int" + "course_id": { + "type": "integer" }, - "regex_expressions": { - "type": "array", - "items": { - "regex": "string" - } + "visible_for_students": { + "type": "boolean" }, - "script_name": { - "type": "string" + "archived": { + "type": "boolean" }, "test_path": { "type": "string" }, - "title": { + "script_name": { "type": "string" }, - "visible_for_students": { - "type": "bool" + "regex_expressions": { + "type": "array" } } } @@ -153,8 +224,32 @@ "schema": { "type": "object", "properties": { + "data": { + "type": "object" + }, "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something in the database went wrong fetching the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -165,6 +260,37 @@ }, "patch": { "description": "Patch certain fields of a project", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + } + } + } + } + }, "responses": { "200": { "description": "Patched a project succesfully", @@ -173,7 +299,13 @@ "schema": { "type": "object", "properties": { - "message": "string" + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "url": { "type": "string" } } } } @@ -188,6 +320,27 @@ "properties": { "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to patch the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -198,6 +351,17 @@ }, "delete": { "description": "Delete a project with given id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "Removed a project succesfully", @@ -206,7 +370,8 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } } } } @@ -219,7 +384,22 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to remove the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "url": { "type": "string" } } } } @@ -288,34 +468,34 @@ } } } - }, - "parameters": [ - { - "name": "name", - "in": "query", - "description": "Name of the course", - "schema": { - "type": "string" - } - }, - { - "name": "ufora_id", - "in": "query", - "description": "Ufora ID of the course", - "schema": { - "type": "string" - } - }, - { - "name": "teacher", - "in": "query", - "description": "Teacher of the course", - "schema": { - "type": "string" - } - } - ] }, + "parameters": [ + { + "name": "name", + "in": "query", + "description": "Name of the course", + "schema": { + "type": "string" + } + }, + { + "name": "ufora_id", + "in": "query", + "description": "Ufora ID of the course", + "schema": { + "type": "string" + } + }, + { + "name": "teacher", + "in": "query", + "description": "Teacher of the course", + "schema": { + "type": "string" + } + } + ] + }, "post": { "description": "Create a new course.", "requestBody": { @@ -333,37 +513,25 @@ "description": "Teacher of the course" } }, - "required": ["name", "teacher"] + "required": [ + "name", + "teacher" + ] } } } }, - "parameters":[ + "parameters": [ { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, + "responses": { "201": { "description": "Course with name: {name} and course_id: {course_id} was successfully created", "content": { @@ -399,8 +567,8 @@ } } }, - "403": { - "description": "The user trying to create a course was unauthorized.", + "400": { + "description": "There was no uid in the request query.", "content": { "application/json": { "schema": { @@ -414,8 +582,8 @@ } } }, - "500": { - "description": "Internal server error.", + "403": { + "description": "The user trying to create a course was unauthorized.", "content": { "application/json": { "schema": { @@ -443,18 +611,34 @@ } } } - } - } - }}, - "/courses/{course_id}" : { - "get": { - "description": "Get a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/courses/{course_id}": { + "get": { + "description": "Get a course by its ID.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, "schema": { "type": "string" } @@ -563,18 +747,22 @@ "properties": { "message": { "type": "string", - "example": "Successfully deleted course with course_id: {course_id}" + "examples": [ + "Successfully deleted course with course_id: {course_id}" + ] }, "url": { "type": "string", - "example": "{API_URL}/courses" + "examples": [ + "{API_URL}/courses" + ] } } } } } }, - "403" : { + "403": { "description": "The user trying to delete the course was unauthorized.", "content": { "application/json": { @@ -621,7 +809,7 @@ } } }, - "patch":{ + "patch": { "description": "Update the course with given ID.", "parameters": [ { @@ -642,25 +830,22 @@ "properties": { "name": { "type": "string", - "description": "Name of the course", - "required" : false + "description": "Name of the course" }, "teacher": { "type": "string", - "description": "Teacher of the course", - "required" : false + "description": "Teacher of the course" }, "ufora_id": { "type": "string", - "description": "Ufora ID of the course", - "required" : false + "description": "Ufora ID of the course" } } } } } }, - "responses" : { + "responses": { "200": { "description": "Course updated.", "content": { @@ -696,7 +881,7 @@ } } }, - "403" : { + "403": { "description": "The user trying to update the course was unauthorized.", "content": { "application/json": { @@ -812,7 +997,7 @@ } } }, - "post":{ + "post": { "description": "Assign students to a course.", "parameters": [ { @@ -834,30 +1019,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "Students assigned to course.", "content": { @@ -867,11 +1037,15 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully added to the course" + "examples": [ + "User were succesfully added to the course" + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -880,7 +1054,9 @@ "type": "array", "items": { "type": "string", - "example": "http://api.example.com/users/123" + "examples": [ + "http://api.example.com/users/123" + ] } } } @@ -890,6 +1066,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign students to the course was unauthorized.", "content": { @@ -937,7 +1128,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove students from a course.", "parameters": [ { @@ -959,15 +1150,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "Students removed from course.", "content": { @@ -977,11 +1168,13 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully removed from the course" + "examples": "User were succesfully removed from the course" }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1003,7 +1196,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove students from the course was unauthorized.", "content": { "application/json": { @@ -1018,8 +1211,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1033,8 +1226,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1048,8 +1241,8 @@ } } } - } - } + } + } }, "/courses/{course_id}/admins": { "get": { @@ -1119,7 +1312,7 @@ } } }, - "post":{ + "post": { "description": "Assign admins to a course.", "parameters": [ { @@ -1141,30 +1334,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "User were successfully added to the course.", "content": { @@ -1174,11 +1352,15 @@ "properties": { "message": { "type": "string", - "example": "User were successfully added to the course." + "examples": [ + "User were successfully added to the course." + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -1188,7 +1370,10 @@ "items": { "type": "string" }, - "example": ["http://api.example.com/users/1", "http://api.example.com/users/2"] + "examples": [ + "http://api.example.com/users/1", + "http://api.example.com/users/2" + ] } } } @@ -1197,6 +1382,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign admins to the course was unauthorized.", "content": { @@ -1244,7 +1444,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove an admin from a course.", "parameters": [ { @@ -1266,15 +1466,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "User was successfully removed from the course admins.", "content": { @@ -1284,11 +1484,15 @@ "properties": { "message": { "type": "string", - "example": "User was successfully removed from the course admins." + "examples": [ + "User was successfully removed from the course admins." + ] }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1310,7 +1514,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove the admin from the course was unauthorized.", "content": { "application/json": { @@ -1325,8 +1529,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1340,8 +1544,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1374,362 +1578,389 @@ "uid": { "type": "string" }, - "role": { - "type": "enum" - } - }, - "required": ["uid", "role"] - } - } - } - } - }}}, - "post": { - "summary": "Create a new user", - "requestBody": { - "required": true, - "content":{ - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" + "is_teacher": { + "type": "boolean" }, - "role": { - "type": "enum" + "is_admin": { + "type": "boolean" } }, - "required": ["uid", "role"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } - }, - "responses": { - "201": { - "description": "User created successfully" - }, - "400": { - "description": "Invalid request data" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while creating the user" - } - } - - }, - "/users/{user_id}": { - "get": { - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "A user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "role": { - "type": "enum" - } - }, - "required": ["uid", "role"] - } + } + } + } + }, + "post": { + "summary": "Create a new user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" } - } - }, - "404": { - "description": "User not found" + }, + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } + } + }, + "responses": { + "201": { + "description": "User created successfully" }, - "patch": { - "summary": "Update a user's information", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { + "400": { + "description": "Invalid request data" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while creating the user" + } + } + }, + "/users/{user_id}": { + "get": { + "summary": "Get a user by ID", + "parameters": [ + { + "name": "user_id", + "in": "path", "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A user", "content": { "application/json": { "schema": { "type": "object", "properties": { - "role": { - "type": "role" + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" } }, - "required": ["role"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } }, - "responses": { - "200": { - "description": "User updated successfully" - }, - "404": { - "description": "User not found" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while patching the user" - } - } - }, - "delete": { - "summary": "Delete a user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "404": { - "description": "User not found" - }, - "500": { - "description": "An error occurred while deleting the user" - } + "404": { + "description": "User not found" } } - } - } - }, - "/submissions": { - "get": { - "summary": "Gets the submissions", - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "User ID", - "schema": { - "type": "string" - } - }, - { - "name": "project_id", - "in": "query", - "description": "Project ID", - "schema": { - "type": "integer" + }, + "patch": { + "summary": "Update a user's information", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved a list of submission URLs", + ], + "requestBody": { + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "is_teacher": { + "type": "boolean" }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submissions": "array", - "items": { - "type": "string", - "format": "uri" - } - } + "is_admin": { + "type": "boolean" } - } + }, + "required": [ + "is_teacher", + "is_admin" + ] } } } }, - "400": { - "description": "An invalid user or project is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" + "responses": { + "200": { + "description": "User updated successfully" + }, + "404": { + "description": "User not found" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while patching the user" + } + } + }, + "delete": { + "summary": "Delete a user", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted successfully" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "An error occurred while deleting the user" + } + } + } + } + } + }, + "/submissions": { + "get": { + "summary": "Gets the submissions", + "parameters": [ + { + "name": "uid", + "in": "query", + "description": "User ID", + "schema": { + "type": "string" + } + }, + { + "name": "project_id", + "in": "query", + "description": "Project ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved a list of submission URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submissions": "array", + "items": { + "type": "string", + "format": "uri" + } } } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user or project is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } - } - }, - "post": { - "summary": "Posts a new submission to a project", - "requestBody": { - "description": "Form data", + }, + "500": { + "description": "An internal server error occurred", "content": { "application/json": { "schema": { "type": "object", "properties": { - "uid": { + "url": { "type": "string", - "required": true - }, - "project_id": { - "type": "integer", - "required": true + "format": "uri" }, - "files": { - "type": "array", - "items": { - "type": "file" - } + "message": { + "type": "string" } } } } } - }, - "responses": { - "201": { - "description": "Successfully posts the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + } + }, + "post": { + "summary": "Posts a new submission to a project", + "requestBody": { + "description": "Form data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "required": true + }, + "project_id": { + "type": "integer", + "required": true + }, + "files": { + "type": "array", + "items": { + "type": "file" + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Successfully posts the submission and retrieves its data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid user, project or list of files is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user, project or list of files is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } @@ -1737,56 +1968,56 @@ } } } - }, - "/submissions/{submission_id}": { - "get": { - "summary": "Gets the submission", - "responses": { - "200": { - "description": "Successfully retrieved the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submission": { - "type": "object", - "properties": { - "submission_id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer", - "nullable": true - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + }, + "/submissions/{submission_id}": { + "get": { + "summary": "Gets the submission", + "responses": { + "200": { + "description": "Successfully retrieved the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submission": { + "type": "object", + "properties": { + "submission_id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer", + "nullable": true + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } @@ -1795,246 +2026,247 @@ } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } + } + }, + "patch": { + "summary": "Patches the submission", + "requestBody": { + "description": "The submission data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "integer", + "minimum": 0, + "maximum": 20 + } + } + } + } + } }, - "patch": { - "summary": "Patches the submission", - "requestBody": { - "description": "The submission data", + "responses": { + "200": { + "description": "Successfully patches the submission and retrieves its data", "content": { "application/json": { "schema": { "type": "object", "properties": { - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successfully patches the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid submission grading is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid submission grading is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "delete": { - "summary": "Deletes the submission", - "responses": { - "200": { - "description": "Successfully deletes the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "delete": { + "summary": "Deletes the submission", + "responses": { + "200": { + "description": "Successfully deletes the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "parameters": [ - { - "name": "submission_id", - "in": "path", - "description": "Submission ID", - "required": true, - "schema": { - "type": "integer" - } + } + }, + "parameters": [ + { + "name": "submission_id", + "in": "path", + "description": "Submission ID", + "required": true, + "schema": { + "type": "integer" } - ] - } + } + ] } +} \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 1bbc2e9e..7d997769 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,4 @@ pytest~=8.0.1 SQLAlchemy~=2.0.27 requests>=2.31.0 waitress +flask_swagger_ui diff --git a/backend/tests.yaml b/backend/tests.yaml index 397702d8..dad5289a 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -43,6 +43,8 @@ services: API_HOST: http://api_is_here AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments + DOCS_JSON_PATH: static/OpenAPI_Object.json + DOCS_URL: /docs volumes: - .:/app command: ["pytest"] From f8fee400cd021b4794668e4703f182b8de5001d4 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Wed, 27 Mar 2024 17:43:49 +0100 Subject: [PATCH 223/377] Base setup for easy translations in frontend (#145) * i18n setup * added dependencies --- frontend/package-lock.json | 136 ++++++++++++++++++++ frontend/package.json | 4 + frontend/public/locales/en/translation.json | 7 + frontend/public/locales/nl/translation.json | 7 + frontend/src/components/Header/Header.tsx | 4 +- frontend/src/i18n.js | 19 +++ frontend/src/main.tsx | 1 + frontend/src/pages/home/Home.tsx | 4 +- 8 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 frontend/public/locales/en/translation.json create mode 100644 frontend/public/locales/nl/translation.json create mode 100644 frontend/src/i18n.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4fc8aea3..8467e285 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,10 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", + "react-i18next": "^14.1.0", "typescript": "^5.2.2", "vite": "^5.1.0" } @@ -2731,6 +2735,15 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4002,6 +4015,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -4025,6 +4047,47 @@ "node": ">=8.12.0" } }, + "node_modules/i18next": { + "version": "23.10.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.1.tgz", + "integrity": "sha512-NDiIzFbcs3O9PXpfhkjyf7WdqFn5Vq6mhzhtkXzj51aOcNuPNcTwuYNuXCpHsanZGHlHKL35G7huoFeVic1hng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz", + "integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.0.tgz", + "integrity": "sha512-Z/aQsGZk1gSxt2/DztXk92DuDD20J+rNudT7ZCdTrNOiK8uQppfvdjq9+DFQfpAnFPn3VZS+KQIr1S/W1KxhpQ==", + "dev": true, + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4780,6 +4843,26 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -5186,6 +5269,28 @@ "react": "^18.2.0" } }, + "node_modules/react-i18next": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.0.tgz", + "integrity": "sha512-3KwX6LHpbvGQ+sBEntjV4sYW3Zovjjl3fpoHbUwSgFHf0uRBcbeCBLR5al6ikncI5+W0EFb71QXZmfop+J6NrQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -5865,6 +5970,12 @@ "node": ">= 4.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "node_modules/ts-api-utils": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", @@ -6089,6 +6200,31 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4645a37b..dfb8c6fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,10 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", + "react-i18next": "^14.1.0", "typescript": "^5.2.2", "vite": "^5.1.0" } diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json new file mode 100644 index 00000000..1447580c --- /dev/null +++ b/frontend/public/locales/en/translation.json @@ -0,0 +1,7 @@ +{ + "homepage": "Homepage", + "myProjects": "My Projects", + "myCourses": "My Courses", + "login": "Login", + "home": "Home" + } \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json new file mode 100644 index 00000000..c852df96 --- /dev/null +++ b/frontend/public/locales/nl/translation.json @@ -0,0 +1,7 @@ +{ + "homepage": "Homepage", + "myProjects": "Mijn Projecten", + "myCourses": "Mijn Vakken", + "login": "Login", + "home": "Home" + } \ No newline at end of file diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 860086fd..8595e6d4 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -7,12 +7,14 @@ import { Typography, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; +import { useTranslation } from "react-i18next"; /** * The header component for the application that will be rendered at the top of the page. * @returns - The header component */ export function Header(): JSX.Element { + const { t } = useTranslation(); return ( @@ -21,7 +23,7 @@ export function Header(): JSX.Element { - Home + {t('home')} diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 00000000..98055d4a --- /dev/null +++ b/frontend/src/i18n.js @@ -0,0 +1,19 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: true, + + interpolation: { + escapeValue: false, + } + }); + +export default i18n; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3d7150da..9b684efc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' +import './i18n' ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 80610e7c..344fb124 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,12 +1,14 @@ +import { useTranslation } from "react-i18next"; /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { + const { t } = useTranslation(); return (

-

HomePage

+

{t('homepage')}

); } From c89e4d55bb39678d51938c588265c7c8a3761af5 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 28 Mar 2024 20:47:30 +0100 Subject: [PATCH 224/377] Fix for issue 102. Submissions get now returns summary information instead of only url (#147) * Fix #102 * linting --- backend/project/endpoints/submissions.py | 104 ++++++++------------ backend/tests/endpoints/submissions_test.py | 8 +- 2 files changed, 46 insertions(+), 66 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index ebc22a88..04c95b56 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -14,6 +14,7 @@ from project.utils.files import filter_files, all_files_uploaded, zip_files from project.utils.user import is_valid_user from project.utils.project import is_valid_project +from project.utils.query_agent import query_selected_from_model, delete_by_id_from_model from project.utils.authentication import authorize_submission_request, \ authorize_submissions_request, authorize_grader, \ authorize_student_submission, authorize_submission_author @@ -21,6 +22,7 @@ load_dotenv() API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/submissions") submissions_bp = Blueprint("submissions", __name__) @@ -36,40 +38,40 @@ def get(self) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", "/submissions") + "url": BASE_URL } try: - with db.session() as session: - query = session.query(Submission) - - # Filter by uid - uid = request.args.get("uid") - if uid is not None: - if session.get(User, uid) is not None: - query = query.filter_by(uid=uid) - else: - data["message"] = f"Invalid user (uid={uid})" - return data, 400 - - # Filter by project_id - project_id = request.args.get("project_id") - if project_id is not None: - if not project_id.isdigit() or session.get(Project, int(project_id)) is None: - data["message"] = f"Invalid project (project_id={project_id})" - return data, 400 - query = query.filter_by(project_id=int(project_id)) - - # Get the submissions - data["message"] = "Successfully fetched the submissions" - data["data"] = [ - urljoin(f"{API_HOST}/", f"/submissions/{s.submission_id}") for s in query.all() - ] - return data, 200 - + # Filter by uid + uid = request.args.get("uid") + if uid is not None and (not uid.isdigit() or not User.query.filter_by(uid=uid).first()): + data["message"] = f"Invalid user (uid={uid})" + return data, 400 + + # Filter by project_id + project_id = request.args.get("project_id") + if project_id is not None \ + and (not project_id.isdigit() or + not Project.query.filter_by(project_id=project_id).first()): + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 except exc.SQLAlchemyError: data["message"] = "An error occurred while fetching the submissions" return data, 500 + return query_selected_from_model( + Submission, + urljoin(f"{API_HOST}/", "/submissions"), + select_values=[ + "submission_id", "uid", + "project_id", "grading", + "submission_time", "submission_status"], + url_mapper={ + "submission_id": BASE_URL, + "project_id": urljoin(f"{API_HOST}/", "projects"), + "uid": urljoin(f"{API_HOST}/", "users")}, + filters=request.args + ) + @authorize_student_submission def post(self) -> dict[str, any]: """Post a new submission to a project @@ -79,7 +81,7 @@ def post(self) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", "/submissions") + "url": BASE_URL } try: with db.session() as session: @@ -129,12 +131,11 @@ def post(self) -> dict[str, any]: data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { - "id": submission.submission_id, + "id": urljoin(f"{BASE_URL}/", submission.submission_id), "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, - "path": submission.submission_path, "status": submission.submission_status } return data, 201 @@ -159,7 +160,7 @@ def get(self, submission_id: int) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + "url": urljoin(f"{BASE_URL}/", str(submission_id)) } try: with db.session() as session: @@ -171,12 +172,11 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = "Successfully fetched the submission" data["data"] = { - "id": submission.submission_id, + "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, - "path": submission.submission_path, "status": submission.submission_status } return data, 200 @@ -198,7 +198,7 @@ def patch(self, submission_id:int) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + "url": urljoin(f"{BASE_URL}/", str(submission_id)) } try: with db.session() as session: @@ -227,14 +227,13 @@ def patch(self, submission_id:int) -> dict[str, any]: session.commit() data["message"] = f"Submission (submission_id={submission_id}) patched" - data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") + data["url"] = urljoin(f"{BASE_URL}/", str(submission.submission_id)) data["data"] = { - "id": submission.submission_id, + "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, - "path": submission.submission_path, "status": submission.submission_status } return data, 200 @@ -256,29 +255,12 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = { - "url": urljoin(f"{API_HOST}/", "/submissions") - } - try: - with db.session() as session: - submission = session.get(Submission, submission_id) - if submission is None: - data["url"] = urljoin(f"{API_HOST}/", "/submissions") - data["message"] = f"Submission (submission_id={submission_id}) not found" - return data, 404 - - # Delete the submission - session.delete(submission) - session.commit() - - data["message"] = f"Submission (submission_id={submission_id}) deleted" - return data, 200 - - except exc.SQLAlchemyError: - db.session.rollback() - data["message"] = \ - f"An error occurred while deleting submission (submission_id={submission_id})" - return data, 500 + return delete_by_id_from_model( + Submission, + "submission_id", + submission_id, + BASE_URL + ) submissions_bp.add_url_rule("/submissions", view_func=SubmissionsEndpoint.as_view("submissions")) submissions_bp.add_url_rule( diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index a900bb84..1354b4be 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -59,12 +59,11 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" assert data["data"] == { - "id": submission.submission_id, + "id": f"{API_HOST}/submissions/{submission.submission_id}", "user": f"{API_HOST}/users/student01", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 16, "time": "Thu, 14 Mar 2024 12:00:00 GMT", - "path": "/submissions/1", "status": 'SUCCESS' } @@ -117,12 +116,11 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" assert data["url"] == f"{API_HOST}/submissions/{submission.submission_id}" assert data["data"] == { - "id": submission.submission_id, + "id": f"{API_HOST}/submissions/{submission.submission_id}", "user": f"{API_HOST}/users/student02", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 20, "time": 'Thu, 14 Mar 2024 23:59:59 GMT', - "path": "/submissions/2", "status": 'FAIL' } @@ -144,7 +142,7 @@ def test_delete_submission_correct(self, client: FlaskClient, session: Session): headers={"Authorization":"student01"}) data = response.json assert response.status_code == 200 - assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" + assert data["message"] == "Resource deleted successfully" assert submission.submission_id not in list(map( lambda s: s.submission_id, session.query(Submission).all() )) From 7dc0ef497462adcc78979d4e4141db7e8841b1eb Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 29 Mar 2024 09:52:51 +0100 Subject: [PATCH 225/377] Correctly uploading submission files (#136) * 116 - Correctly uploading submission files * #116 - Spelling * Remove break * Remove unused variable * Specify which files are required * Remove unnecessary stringify of UPLOAD_FOLDER * Update the submission_path path.join * Catching OSErrors when making directories and saving files * Update the run_tests script --- backend/project/endpoints/submissions.py | 40 +++++++++++++++--------- backend/project/utils/files.py | 25 ++++++++++----- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 04c95b56..9ce2db66 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,7 +2,8 @@ from urllib.parse import urljoin from datetime import datetime -from os import getenv, path +from os import getenv, path, makedirs +from shutil import rmtree from dotenv import load_dotenv from flask import Blueprint, request from flask_restful import Resource @@ -11,7 +12,7 @@ from project.models.submission import Submission, SubmissionStatus from project.models.project import Project from project.models.user import User -from project.utils.files import filter_files, all_files_uploaded, zip_files +from project.utils.files import filter_files, all_files_uploaded from project.utils.user import is_valid_user from project.utils.project import is_valid_project from project.utils.query_agent import query_selected_from_model, delete_by_id_from_model @@ -106,28 +107,37 @@ def post(self) -> dict[str, any]: # Submission time submission.submission_time = datetime.now() - # Submission path + # Submission status + submission.submission_status = SubmissionStatus.RUNNING + + # Submission files + submission.submission_path = "" # Must be set on creation files = filter_files(request.files.getlist("files")) + + # Check files otherwise stop project = session.get(Project, submission.project_id) if not files or not all_files_uploaded(files, project.regex_expressions): data["message"] = "No files were uploaded" if not files else \ - "Not all required files were uploaded" + "Not all required files were uploaded " \ + f"(required files={','.join(project.regex_expressions)})" return data, 400 - # Zip the files and save the zip - zip_file = zip_files("", files) - if zip_file is None: - data["message"] = "Something went wrong while zipping the files" - return data, 500 - submission.submission_path = "/zip.zip" - zip_file.save(path.join(f"{UPLOAD_FOLDER}/", submission.submission_path)) - - # Submission status - submission.submission_status = SubmissionStatus.RUNNING - + # Submission_id needed for the file location session.add(submission) session.commit() + # Save the files + submission.submission_path = path.join(UPLOAD_FOLDER, str(submission.project_id), + "submissions", str(submission.submission_id)) + try: + makedirs(submission.submission_path, exist_ok=True) + for file in files: + file.save(path.join(submission.submission_path, file.filename)) + session.commit() + except OSError: + rmtree(submission.submission_path) + session.rollback() + data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index b577e218..3c8d381c 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -2,9 +2,9 @@ from os.path import getsize from re import match -from typing import List, Union +from typing import List, Optional from io import BytesIO -from zipfile import ZipFile, ZIP_DEFLATED +from zipfile import ZipFile, ZIP_DEFLATED, is_zipfile from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage @@ -33,15 +33,24 @@ def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: bool: Are all required files uploaded """ - all_uploaded = True + all_filenames = [] + for file in files: + # Zip + if is_zipfile(file): + with ZipFile(file, "r") as zip_file: + all_filenames += zip_file.namelist() + # File + else: + all_filenames.append(file.filename) + for regex in regexes: - match_found = any(match(regex, file.filename) is not None for file in files) + match_found = any(match(regex, name) is not None for name in all_filenames) if not match_found: - all_uploaded = False - break - return all_uploaded + return False + + return True -def zip_files(name: str, files: List[FileStorage]) -> Union[FileStorage, None]: +def zip_files(name: str, files: List[FileStorage]) -> Optional[FileStorage]: """Zip a dictionary of files Args: From d4aa9513694dcf327e74f5ff023e72da6393a7b3 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Fri, 29 Mar 2024 09:53:33 +0100 Subject: [PATCH 226/377] This should allow frontend to communicate with our backend (#144) * allow cors * moved CORS to create_app_with_db --- backend/project/__init__.py | 3 ++- backend/requirements.txt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 9c71aafd..4a61535d 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -3,6 +3,7 @@ """ from flask import Flask +from flask_cors import CORS from .db_in import db from .endpoints.index.index import index_bp from .endpoints.users import users_bp @@ -44,5 +45,5 @@ def create_app_with_db(db_uri: str): app.config["SQLALCHEMY_DATABASE_URI"] = db_uri app.config["UPLOAD_FOLDER"] = "/" db.init_app(app) - + CORS(app) return app diff --git a/backend/requirements.txt b/backend/requirements.txt index 7d997769..f47e98e6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ flask~=3.0.2 +flask-cors flask-restful flask-sqlalchemy python-dotenv~=1.0.1 From 5d21644478bbaeee8835dc9f6b80c951656d2315 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sat, 30 Mar 2024 13:11:53 +0100 Subject: [PATCH 227/377] Test invalid user form (#137) * added test for invalid user form * traling whitespace * post to /users is not allowed * removed double invalid fixture --- backend/tests/endpoints/user_test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 7d3a0c39..8c4632ad 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -84,6 +84,12 @@ def test_post_authenticated(self, client, valid_user): headers={"Authorization":"teacher1"}) assert response.status_code == 403 # POST to /users is not allowed + def test_wrong_form_post(self, client, user_invalid_field): + """Test posting with a wrong form.""" + response = client.post("/users", data=user_invalid_field, + headers={"Authorization":"teacher1"}) + assert response.status_code == 403 + def test_get_all_users(self, client, valid_user_entries): """Test getting all users.""" response = client.get("/users", headers={"Authorization":"teacher1"}) From 22bf99e234d79e740eaa5e4ddaa12aa769de87ef Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sat, 30 Mar 2024 13:49:49 +0100 Subject: [PATCH 228/377] Test for course with invalid field (#125) * added test for course with invalid field * authorization header! * fix --- backend/tests/endpoints/conftest.py | 6 ++++++ backend/tests/endpoints/course/courses_test.py | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 8a1c53ab..b76e8369 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -206,6 +206,12 @@ def valid_teacher_entry(session): session.rollback() return teacher +@pytest.fixture +def course_invalid_field(valid_course): + """A course with an invalid field""" + valid_course["test_field"] = "test_value" + return valid_course + @pytest.fixture def valid_course(valid_teacher_entry): """A valid course json form""" diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index b82d2728..d2c7cfd6 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -35,6 +35,14 @@ def test_post_no_name(self, client, course_empty_name): response = client.post("/courses?uid=Bart", json=course_empty_name, headers={"Authorization": "teacher2"}) assert response.status_code == 400 + def test_post_with_invalid_fields(self, client, course_invalid_field): + """ + Test posting a course with invalid fields + """ + + response = client.post("/courses", json=course_invalid_field, + headers={"Authorization":"teacher2"}) + assert response.status_code == 201 def test_post_courses_course_id_students_and_admins( self, client, valid_course_entry, valid_students_entries): From e4e1cb473a19afdc942acbc0e0241002010b3466 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 30 Mar 2024 18:21:15 +0100 Subject: [PATCH 229/377] Merge with backend/tests/auth --- README.md | 22 +- backend/db_construct.sql | 5 +- backend/project/__init__.py | 5 +- .../endpoints/courses/courses_utils.py | 19 +- .../project/endpoints/docs/docs_endpoint.py | 17 + backend/project/endpoints/index/index.py | 7 +- backend/project/endpoints/submissions.py | 144 +- backend/project/endpoints/users.py | 45 +- backend/project/models/user.py | 23 +- .../index => static}/OpenAPI_Object.json | 1654 ++++++++++------- backend/project/utils/authentication.py | 87 +- backend/project/utils/files.py | 25 +- backend/project/utils/models/user_utils.py | 6 +- backend/project/utils/query_agent.py | 2 +- backend/requirements.txt | 2 + backend/test_auth_server/__main__.py | 38 +- backend/tests.yaml | 4 +- backend/tests/conftest.py | 34 +- backend/tests/endpoints/conftest.py | 49 +- .../tests/endpoints/course/courses_test.py | 34 +- backend/tests/endpoints/endpoint.py | 56 + backend/tests/endpoints/submissions_test.py | 8 +- backend/tests/endpoints/user_test.py | 53 +- backend/tests/models/user_test.py | 16 +- frontend/package-lock.json | 136 ++ frontend/package.json | 4 + frontend/public/locales/en/translation.json | 7 + frontend/public/locales/nl/translation.json | 7 + frontend/src/components/Header/Header.tsx | 4 +- frontend/src/i18n.js | 19 + frontend/src/main.tsx | 1 + frontend/src/pages/home/Home.tsx | 4 +- 32 files changed, 1583 insertions(+), 954 deletions(-) create mode 100644 backend/project/endpoints/docs/docs_endpoint.py rename backend/project/{endpoints/index => static}/OpenAPI_Object.json (66%) create mode 100644 backend/tests/endpoints/endpoint.py create mode 100644 frontend/public/locales/en/translation.json create mode 100644 frontend/public/locales/nl/translation.json create mode 100644 frontend/src/i18n.js diff --git a/README.md b/README.md index c684e92f..0c446923 100644 --- a/README.md +++ b/README.md @@ -1 +1,21 @@ -# UGent-3 \ No newline at end of file +# UGent-3 project peristerónas +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) +## Introduction +Project peristerónas was created to aid both teachers and students in achieving a +clear overview of deadlines and projects that need to be submitted. + +There's a separate functionality depending on if you're logged in as a teacher or as a student. +For students the main functionality is to have a user-friendly interface to submit projects and check the correctness of their submissions. + +When a teacher is logged in they can get an overview of the projects he assigned and check how many students have already +handed in a correct solution for example. It's also possible to edit the project and to grade projects in peristerónas' interface. +## Usage +### Frontend +For the developer instructions of the frontend please refer to the [frontend readme](frontend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. +### Backend +For the developer instructions of the backend please refer to the [backend readme](backend/README.md) +where clear instructions can be found for usage, test cases, deployment and development. diff --git a/backend/db_construct.sql b/backend/db_construct.sql index bb7c7eb7..e3f6af41 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,9 +1,10 @@ +CREATE TYPE role AS ENUM ('STUDENT', 'TEACHER', 'ADMIN'); + CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); CREATE TABLE users ( uid VARCHAR(255), - is_teacher BOOLEAN, - is_admin BOOLEAN, + role role NOT NULL, PRIMARY KEY(uid) ); diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 664ff947..4a61535d 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -3,6 +3,7 @@ """ from flask import Flask +from flask_cors import CORS from .db_in import db from .endpoints.index.index import index_bp from .endpoints.users import users_bp @@ -10,6 +11,7 @@ from .endpoints.projects.project_endpoint import project_bp from .endpoints.submissions import submissions_bp from .endpoints.courses.join_codes.join_codes_config import join_codes_bp +from .endpoints.docs.docs_endpoint import swagger_ui_blueprint def create_app(): """ @@ -25,6 +27,7 @@ def create_app(): app.register_blueprint(project_bp) app.register_blueprint(submissions_bp) app.register_blueprint(join_codes_bp) + app.register_blueprint(swagger_ui_blueprint) return app @@ -42,5 +45,5 @@ def create_app_with_db(db_uri: str): app.config["SQLALCHEMY_DATABASE_URI"] = db_uri app.config["UPLOAD_FOLDER"] = "/" db.init_app(app) - + CORS(app) return app diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index cb36c6a4..4c01ee73 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -17,7 +17,8 @@ load_dotenv() API_URL = getenv("API_HOST") -RESPONSE_URL = urljoin(f"{API_URL}/", "courses") +RESPONSE_URL = urljoin(API_URL + "/", "courses") +BASE_DB_ERROR = "Database error occurred while" def execute_query_abort_if_db_error(query, url, query_all=False): """ @@ -35,8 +36,8 @@ def execute_query_abort_if_db_error(query, url, query_all=False): result = query.all() else: result = query.first() - except SQLAlchemyError as e: - response = json_message(str(e)) + except SQLAlchemyError: + response = json_message(f"{BASE_DB_ERROR} executing query") response["url"] = url abort(500, description=response) return result @@ -52,9 +53,9 @@ def add_abort_if_error(to_add, url): """ try: db.session.add(to_add) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} adding object") response["url"] = url abort(500, description=response) @@ -69,9 +70,9 @@ def delete_abort_if_error(to_delete, url): """ try: db.session.delete(to_delete) - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} deleting object") response["url"] = url abort(500, description=response) @@ -82,9 +83,9 @@ def commit_abort_if_error(url): """ try: db.session.commit() - except SQLAlchemyError as e: + except SQLAlchemyError: db.session.rollback() - response = json_message(str(e)) + response = json_message(f"{BASE_DB_ERROR} committing changes") response["url"] = url abort(500, description=response) diff --git a/backend/project/endpoints/docs/docs_endpoint.py b/backend/project/endpoints/docs/docs_endpoint.py new file mode 100644 index 00000000..197641ae --- /dev/null +++ b/backend/project/endpoints/docs/docs_endpoint.py @@ -0,0 +1,17 @@ +""" +Module for defining the swagger docs +""" + +from os import getenv +from flask_swagger_ui import get_swaggerui_blueprint + +SWAGGER_URL = getenv("DOCS_URL") +API_URL = getenv("DOCS_JSON_PATH") + +swagger_ui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + f"/{API_URL}", + config={ + 'app_name': 'Pigeonhole API' + } +) diff --git a/backend/project/endpoints/index/index.py b/backend/project/endpoints/index/index.py index 1bfe67cb..4feb3382 100644 --- a/backend/project/endpoints/index/index.py +++ b/backend/project/endpoints/index/index.py @@ -1,11 +1,13 @@ """Index api point""" import os -from flask import Blueprint, send_from_directory +from flask import Blueprint, send_file from flask_restful import Resource, Api index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) +API_URL = os.getenv("DOCS_JSON_PATH") + class Index(Resource): """Api endpoint for the / route""" @@ -14,8 +16,7 @@ def get(self): Example of an api endpoint function that will respond to get requests made to return a json data structure with key Message and value Hello World! """ - dir_path = os.path.dirname(os.path.realpath(__file__)) - return send_from_directory(dir_path, "OpenAPI_Object.json") + return send_file(API_URL) index_bp.add_url_rule("/", view_func=Index.as_view("index")) diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index ebc22a88..9ce2db66 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -2,7 +2,8 @@ from urllib.parse import urljoin from datetime import datetime -from os import getenv, path +from os import getenv, path, makedirs +from shutil import rmtree from dotenv import load_dotenv from flask import Blueprint, request from flask_restful import Resource @@ -11,9 +12,10 @@ from project.models.submission import Submission, SubmissionStatus from project.models.project import Project from project.models.user import User -from project.utils.files import filter_files, all_files_uploaded, zip_files +from project.utils.files import filter_files, all_files_uploaded from project.utils.user import is_valid_user from project.utils.project import is_valid_project +from project.utils.query_agent import query_selected_from_model, delete_by_id_from_model from project.utils.authentication import authorize_submission_request, \ authorize_submissions_request, authorize_grader, \ authorize_student_submission, authorize_submission_author @@ -21,6 +23,7 @@ load_dotenv() API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/submissions") submissions_bp = Blueprint("submissions", __name__) @@ -36,40 +39,40 @@ def get(self) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", "/submissions") + "url": BASE_URL } try: - with db.session() as session: - query = session.query(Submission) - - # Filter by uid - uid = request.args.get("uid") - if uid is not None: - if session.get(User, uid) is not None: - query = query.filter_by(uid=uid) - else: - data["message"] = f"Invalid user (uid={uid})" - return data, 400 - - # Filter by project_id - project_id = request.args.get("project_id") - if project_id is not None: - if not project_id.isdigit() or session.get(Project, int(project_id)) is None: - data["message"] = f"Invalid project (project_id={project_id})" - return data, 400 - query = query.filter_by(project_id=int(project_id)) - - # Get the submissions - data["message"] = "Successfully fetched the submissions" - data["data"] = [ - urljoin(f"{API_HOST}/", f"/submissions/{s.submission_id}") for s in query.all() - ] - return data, 200 - + # Filter by uid + uid = request.args.get("uid") + if uid is not None and (not uid.isdigit() or not User.query.filter_by(uid=uid).first()): + data["message"] = f"Invalid user (uid={uid})" + return data, 400 + + # Filter by project_id + project_id = request.args.get("project_id") + if project_id is not None \ + and (not project_id.isdigit() or + not Project.query.filter_by(project_id=project_id).first()): + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 except exc.SQLAlchemyError: data["message"] = "An error occurred while fetching the submissions" return data, 500 + return query_selected_from_model( + Submission, + urljoin(f"{API_HOST}/", "/submissions"), + select_values=[ + "submission_id", "uid", + "project_id", "grading", + "submission_time", "submission_status"], + url_mapper={ + "submission_id": BASE_URL, + "project_id": urljoin(f"{API_HOST}/", "projects"), + "uid": urljoin(f"{API_HOST}/", "users")}, + filters=request.args + ) + @authorize_student_submission def post(self) -> dict[str, any]: """Post a new submission to a project @@ -79,7 +82,7 @@ def post(self) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", "/submissions") + "url": BASE_URL } try: with db.session() as session: @@ -104,37 +107,45 @@ def post(self) -> dict[str, any]: # Submission time submission.submission_time = datetime.now() - # Submission path + # Submission status + submission.submission_status = SubmissionStatus.RUNNING + + # Submission files + submission.submission_path = "" # Must be set on creation files = filter_files(request.files.getlist("files")) + + # Check files otherwise stop project = session.get(Project, submission.project_id) if not files or not all_files_uploaded(files, project.regex_expressions): data["message"] = "No files were uploaded" if not files else \ - "Not all required files were uploaded" + "Not all required files were uploaded " \ + f"(required files={','.join(project.regex_expressions)})" return data, 400 - # Zip the files and save the zip - zip_file = zip_files("", files) - if zip_file is None: - data["message"] = "Something went wrong while zipping the files" - return data, 500 - submission.submission_path = "/zip.zip" - zip_file.save(path.join(f"{UPLOAD_FOLDER}/", submission.submission_path)) - - # Submission status - submission.submission_status = SubmissionStatus.RUNNING - + # Submission_id needed for the file location session.add(submission) session.commit() + # Save the files + submission.submission_path = path.join(UPLOAD_FOLDER, str(submission.project_id), + "submissions", str(submission.submission_id)) + try: + makedirs(submission.submission_path, exist_ok=True) + for file in files: + file.save(path.join(submission.submission_path, file.filename)) + session.commit() + except OSError: + rmtree(submission.submission_path) + session.rollback() + data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { - "id": submission.submission_id, + "id": urljoin(f"{BASE_URL}/", submission.submission_id), "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, - "path": submission.submission_path, "status": submission.submission_status } return data, 201 @@ -159,7 +170,7 @@ def get(self, submission_id: int) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + "url": urljoin(f"{BASE_URL}/", str(submission_id)) } try: with db.session() as session: @@ -171,12 +182,11 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = "Successfully fetched the submission" data["data"] = { - "id": submission.submission_id, + "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, - "path": submission.submission_path, "status": submission.submission_status } return data, 200 @@ -198,7 +208,7 @@ def patch(self, submission_id:int) -> dict[str, any]: """ data = { - "url": urljoin(f"{API_HOST}/", f"/submissions/{submission_id}") + "url": urljoin(f"{BASE_URL}/", str(submission_id)) } try: with db.session() as session: @@ -227,14 +237,13 @@ def patch(self, submission_id:int) -> dict[str, any]: session.commit() data["message"] = f"Submission (submission_id={submission_id}) patched" - data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") + data["url"] = urljoin(f"{BASE_URL}/", str(submission.submission_id)) data["data"] = { - "id": submission.submission_id, + "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, "time": submission.submission_time, - "path": submission.submission_path, "status": submission.submission_status } return data, 200 @@ -256,29 +265,12 @@ def delete(self, submission_id: int) -> dict[str, any]: dict[str, any]: A message """ - data = { - "url": urljoin(f"{API_HOST}/", "/submissions") - } - try: - with db.session() as session: - submission = session.get(Submission, submission_id) - if submission is None: - data["url"] = urljoin(f"{API_HOST}/", "/submissions") - data["message"] = f"Submission (submission_id={submission_id}) not found" - return data, 404 - - # Delete the submission - session.delete(submission) - session.commit() - - data["message"] = f"Submission (submission_id={submission_id}) deleted" - return data, 200 - - except exc.SQLAlchemyError: - db.session.rollback() - data["message"] = \ - f"An error occurred while deleting submission (submission_id={submission_id})" - return data, 500 + return delete_by_id_from_model( + Submission, + "submission_id", + submission_id, + BASE_URL + ) submissions_bp.add_url_rule("/submissions", view_func=SubmissionsEndpoint.as_view("submissions")) submissions_bp.add_url_rule( diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 7d073c6c..34e65817 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -7,7 +7,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.user import User as userModel +from project.models.user import User as userModel, Role from project.utils.authentication import login_required, authorize_user, \ authorize_admin, not_allowed @@ -29,16 +29,13 @@ def get(self): """ try: query = userModel.query - is_teacher = request.args.get('is_teacher') - is_admin = request.args.get('is_admin') - - if is_teacher is not None: - query = query.filter(userModel.is_teacher == (is_teacher.lower() == 'true')) - - if is_admin is not None: - query = query.filter(userModel.is_admin == (is_admin.lower() == 'true')) + role = request.args.get("role") + if role is not None: + role = Role[role.upper()] + query = query.filter(userModel.role == role) users = query.all() + users = [user.to_dict() for user in users] result = jsonify({"message": "Queried all users", "data": users, "url":f"{API_URL}/users", "status_code": 200}) @@ -54,26 +51,25 @@ def post(self): It should create a new user and return a success message. """ uid = request.json.get('uid') - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') + role = request.json.get("role") + role = Role[role.upper()] if role is not None else None url = f"{API_URL}/users" - if is_teacher is None or is_admin is None or uid is None: + if role is None or uid is None: return { "message": "Invalid request data!", "correct_format": { "uid": "User ID (string)", - "is_teacher": "Teacher status (boolean)", - "is_admin": "Admin status (boolean)" - },"url": url + "role": "User role (string)" + },"url": f"{API_URL}/users" }, 400 try: user = db.session.get(userModel, uid) if user is not None: # Bad request, error code could be 409 but is rarely used return {"message": f"User {uid} already exists"}, 400 - # Code to create a new user in the database using the uid, is_teacher, and is_admin - new_user = userModel(uid=uid, is_teacher=is_teacher, is_admin=is_admin) + # Code to create a new user in the database using the uid and role + new_user = userModel(uid=uid, role=role) db.session.add(new_user) db.session.commit() return jsonify({"message": "User created successfully!", @@ -99,7 +95,7 @@ def get(self, user_id): if user is None: return {"message": "User not found!","url": f"{API_URL}/users"}, 404 - return jsonify({"message": "User queried","data":user, + return jsonify({"message": "User queried","data":user.to_dict(), "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: return {"message": "An error occurred while fetching the user", @@ -114,22 +110,21 @@ def patch(self, user_id): dict: A dictionary containing the message indicating the success or failure of the update. """ - is_teacher = request.json.get('is_teacher') - is_admin = request.json.get('is_admin') + role = request.json.get("role") + role = Role[role.upper()] if role is not None else None try: user = db.session.get(userModel, user_id) if user is None: return {"message": "User not found!","url": f"{API_URL}/users"}, 404 - if is_teacher is not None: - user.is_teacher = is_teacher - if is_admin is not None: - user.is_admin = is_admin + if role is not None: + user.role = role # Save the changes to the database db.session.commit() return jsonify({"message": "User updated successfully!", - "data": user, "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) + "data": user.to_dict(), + "url": f"{API_URL}/users/{user.uid}", "status_code": 200}) except SQLAlchemyError: # every exception should result in a rollback db.session.rollback() diff --git a/backend/project/models/user.py b/backend/project/models/user.py index bb130349..7cd59fd1 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -1,17 +1,30 @@ """User model""" +from enum import Enum from dataclasses import dataclass -from sqlalchemy import Boolean, Column, String +from sqlalchemy import Column, String, Enum as EnumField from project.db_in import db +class Role(Enum): + """This class defines the roles of a user""" + STUDENT = 0 + TEACHER = 1 + ADMIN = 2 + @dataclass class User(db.Model): """This class defines the users table, - a user has a uid, - is_teacher and is_admin booleans because a user + a user has a uid and a role, a user can be either a student,admin or teacher""" __tablename__ = "users" uid: str = Column(String(255), primary_key=True) - is_teacher: bool = Column(Boolean) - is_admin: bool = Column(Boolean) + role: Role = Column(EnumField(Role), nullable=False) + def to_dict(self): + """ + Converts a User to a serializable dict + """ + return { + 'uid': self.uid, + 'role': self.role.name, # Convert the enum to a string + } diff --git a/backend/project/endpoints/index/OpenAPI_Object.json b/backend/project/static/OpenAPI_Object.json similarity index 66% rename from backend/project/endpoints/index/OpenAPI_Object.json rename to backend/project/static/OpenAPI_Object.json index 829f7c38..ba0b5381 100644 --- a/backend/project/endpoints/index/OpenAPI_Object.json +++ b/backend/project/static/OpenAPI_Object.json @@ -1,5 +1,5 @@ { - "openapi": "3.1.0", + "openapi": "3.0.1", "info": { "title": "Pigeonhole API", "summary": "A project submission and grading API for University Ghent students and professors.", @@ -56,7 +56,7 @@ "type": "object", "properties": { "project_id": { - "type": "int" + "type": "integer" }, "description": { "type": "string" @@ -74,6 +74,27 @@ }, "post": { "description": "Upload a new project", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + }, + "required": ["assignment_file", "title", "description", "course_id", "visible_for_students", "archived"] + } + } + } + }, "responses": { "201": { "description": "Uploaded a new project succesfully", @@ -82,7 +103,51 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { + "type": "string" + }, + "data": { + "type": "object" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Bad formatted request for uploading a project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong inserting model into the database", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error":{ + "type": "string" + }, + "url": { + "type": "string" + } } } } @@ -94,6 +159,17 @@ "/projects/{id}": { "get": { "description": "Return a project with corresponding id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "A project with corresponding id", @@ -102,44 +178,39 @@ "schema": { "type": "object", "properties": { - "archived": { - "type": "bool" + "project_id": { + "type": "integer" }, - "assignment_file": { + "title": { "type": "string" }, - "course_id": { - "type": "int" + "description": { + "type": "string" }, - "deadline": { - "type": "date" + "assignment_file": { + "type": "string", + "format": "binary" }, - "description": { - "type": "array", - "items": { - "description": "string" - } + "deadline": { + "type": "string" }, - "project_id": { - "type": "int" + "course_id": { + "type": "integer" }, - "regex_expressions": { - "type": "array", - "items": { - "regex": "string" - } + "visible_for_students": { + "type": "boolean" }, - "script_name": { - "type": "string" + "archived": { + "type": "boolean" }, "test_path": { "type": "string" }, - "title": { + "script_name": { "type": "string" }, - "visible_for_students": { - "type": "bool" + "regex_expressions": { + "type": "array" } } } @@ -153,8 +224,32 @@ "schema": { "type": "object", "properties": { + "data": { + "type": "object" + }, "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something in the database went wrong fetching the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -165,6 +260,37 @@ }, "patch": { "description": "Patch certain fields of a project", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "assignment_file": { + "type": "string", + "format": "binary" + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "course_id": { "type": "integer" }, + "visible_for_students": { "type": "boolean" }, + "archived": { "type": "boolean" } + } + } + } + } + }, "responses": { "200": { "description": "Patched a project succesfully", @@ -173,7 +299,13 @@ "schema": { "type": "object", "properties": { - "message": "string" + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "url": { "type": "string" } } } } @@ -188,6 +320,27 @@ "properties": { "message": { "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to patch the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + }, + "url": { + "type": "string" } } } @@ -198,6 +351,17 @@ }, "delete": { "description": "Delete a project with given id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the project to retrieve", + "required": true, + "schema": { + "type": "integer" + } + } + ], "responses": { "200": { "description": "Removed a project succesfully", @@ -206,7 +370,8 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } } } } @@ -219,7 +384,22 @@ "schema": { "type": "object", "properties": { - "message": "string" + "message": { "type": "string" }, + "url": { "type": "string" } + } + } + } + } + }, + "500": { + "description": "Something went wrong in the database trying to remove the project", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "url": { "type": "string" } } } } @@ -288,34 +468,34 @@ } } } - }, - "parameters": [ - { - "name": "name", - "in": "query", - "description": "Name of the course", - "schema": { - "type": "string" - } - }, - { - "name": "ufora_id", - "in": "query", - "description": "Ufora ID of the course", - "schema": { - "type": "string" - } - }, - { - "name": "teacher", - "in": "query", - "description": "Teacher of the course", - "schema": { - "type": "string" - } - } - ] }, + "parameters": [ + { + "name": "name", + "in": "query", + "description": "Name of the course", + "schema": { + "type": "string" + } + }, + { + "name": "ufora_id", + "in": "query", + "description": "Ufora ID of the course", + "schema": { + "type": "string" + } + }, + { + "name": "teacher", + "in": "query", + "description": "Teacher of the course", + "schema": { + "type": "string" + } + } + ] + }, "post": { "description": "Create a new course.", "requestBody": { @@ -333,37 +513,25 @@ "description": "Teacher of the course" } }, - "required": ["name", "teacher"] + "required": [ + "name", + "teacher" + ] } } } }, - "parameters":[ + "parameters": [ { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, + "responses": { "201": { "description": "Course with name: {name} and course_id: {course_id} was successfully created", "content": { @@ -399,8 +567,8 @@ } } }, - "403": { - "description": "The user trying to create a course was unauthorized.", + "400": { + "description": "There was no uid in the request query.", "content": { "application/json": { "schema": { @@ -414,8 +582,8 @@ } } }, - "500": { - "description": "Internal server error.", + "403": { + "description": "The user trying to create a course was unauthorized.", "content": { "application/json": { "schema": { @@ -443,18 +611,34 @@ } } } - } - } - }}, - "/courses/{course_id}" : { - "get": { - "description": "Get a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, + }, + "500": { + "description": "Internal server error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/courses/{course_id}": { + "get": { + "description": "Get a course by its ID.", + "parameters": [ + { + "name": "course_id", + "in": "path", + "description": "ID of the course", + "required": true, "schema": { "type": "string" } @@ -563,18 +747,22 @@ "properties": { "message": { "type": "string", - "example": "Successfully deleted course with course_id: {course_id}" + "examples": [ + "Successfully deleted course with course_id: {course_id}" + ] }, "url": { "type": "string", - "example": "{API_URL}/courses" + "examples": [ + "{API_URL}/courses" + ] } } } } } }, - "403" : { + "403": { "description": "The user trying to delete the course was unauthorized.", "content": { "application/json": { @@ -621,7 +809,7 @@ } } }, - "patch":{ + "patch": { "description": "Update the course with given ID.", "parameters": [ { @@ -642,25 +830,22 @@ "properties": { "name": { "type": "string", - "description": "Name of the course", - "required" : false + "description": "Name of the course" }, "teacher": { "type": "string", - "description": "Teacher of the course", - "required" : false + "description": "Teacher of the course" }, "ufora_id": { "type": "string", - "description": "Ufora ID of the course", - "required" : false + "description": "Ufora ID of the course" } } } } } }, - "responses" : { + "responses": { "200": { "description": "Course updated.", "content": { @@ -696,7 +881,7 @@ } } }, - "403" : { + "403": { "description": "The user trying to update the course was unauthorized.", "content": { "application/json": { @@ -812,7 +997,7 @@ } } }, - "post":{ + "post": { "description": "Assign students to a course.", "parameters": [ { @@ -834,30 +1019,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "Students assigned to course.", "content": { @@ -867,11 +1037,15 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully added to the course" + "examples": [ + "User were succesfully added to the course" + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -880,7 +1054,9 @@ "type": "array", "items": { "type": "string", - "example": "http://api.example.com/users/123" + "examples": [ + "http://api.example.com/users/123" + ] } } } @@ -890,6 +1066,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign students to the course was unauthorized.", "content": { @@ -937,7 +1128,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove students from a course.", "parameters": [ { @@ -959,15 +1150,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "Students removed from course.", "content": { @@ -977,11 +1168,13 @@ "properties": { "message": { "type": "string", - "example": "User were succesfully removed from the course" + "examples": "User were succesfully removed from the course" }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1003,7 +1196,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove students from the course was unauthorized.", "content": { "application/json": { @@ -1018,8 +1211,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1033,8 +1226,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1048,8 +1241,8 @@ } } } - } - } + } + } }, "/courses/{course_id}/admins": { "get": { @@ -1119,7 +1312,7 @@ } } }, - "post":{ + "post": { "description": "Assign admins to a course.", "parameters": [ { @@ -1141,30 +1334,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], "responses": { - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, "201": { "description": "User were successfully added to the course.", "content": { @@ -1174,11 +1352,15 @@ "properties": { "message": { "type": "string", - "example": "User were successfully added to the course." + "examples": [ + "User were successfully added to the course." + ] }, "url": { "type": "string", - "example": "http://api.example.com/courses/123/students" + "examples": [ + "http://api.example.com/courses/123/students" + ] }, "data": { "type": "object", @@ -1188,7 +1370,10 @@ "items": { "type": "string" }, - "example": ["http://api.example.com/users/1", "http://api.example.com/users/2"] + "examples": [ + "http://api.example.com/users/1", + "http://api.example.com/users/2" + ] } } } @@ -1197,6 +1382,21 @@ } } }, + "400": { + "description": "There was no uid in the request query.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string" + } + } + } + } + } + }, "403": { "description": "The user trying to assign admins to the course was unauthorized.", "content": { @@ -1244,7 +1444,7 @@ } } }, - "delete":{ + "delete": { "description": "Remove an admin from a course.", "parameters": [ { @@ -1266,15 +1466,15 @@ } }, { - "name":"uid", - "in":"query", - "description":"uid of the user sending the request", - "schema":{ - "type":"string" + "name": "uid", + "in": "query", + "description": "uid of the user sending the request", + "schema": { + "type": "string" } } ], - "responses":{ + "responses": { "204": { "description": "User was successfully removed from the course admins.", "content": { @@ -1284,11 +1484,15 @@ "properties": { "message": { "type": "string", - "example": "User was successfully removed from the course admins." + "examples": [ + "User was successfully removed from the course admins." + ] }, "url": { "type": "string", - "example": "API_URL + '/courses/' + str(course_id) + '/students'" + "examples": [ + "API_URL + /courses/ + str(course_id) + /students" + ] } } } @@ -1310,7 +1514,7 @@ } } }, - "403":{ + "403": { "description": "The user trying to remove the admin from the course was unauthorized.", "content": { "application/json": { @@ -1325,8 +1529,8 @@ } } }, - "500":{ - "description": "Internal server error.", + "404": { + "description": "Course not found.", "content": { "application/json": { "schema": { @@ -1340,8 +1544,8 @@ } } }, - "404":{ - "description": "Course not found.", + "500": { + "description": "Internal server error.", "content": { "application/json": { "schema": { @@ -1381,112 +1585,85 @@ "type": "boolean" } }, - "required": ["uid", "is_teacher", "is_admin"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } } - }}}, - "post": { - "summary": "Create a new user", - "requestBody": { - "required": true, - "content":{ - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": ["uid", "is_teacher", "is_admin"] - } - } - } - }, - "responses": { - "201": { - "description": "User created successfully" - }, - "400": { - "description": "Invalid request data" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while creating the user" - } - } - - }, - "/users/{user_id}": { - "get": { - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "A user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": ["uid", "is_teacher", "is_admin"] - } + } + } + }, + "post": { + "summary": "Create a new user", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string" + }, + "is_teacher": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" } - } - }, - "404": { - "description": "User not found" + }, + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } + } + }, + "responses": { + "201": { + "description": "User created successfully" }, - "patch": { - "summary": "Update a user's information", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { + "400": { + "description": "Invalid request data" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while creating the user" + } + } + }, + "/users/{user_id}": { + "get": { + "summary": "Get a user by ID", + "parameters": [ + { + "name": "user_id", + "in": "path", "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A user", "content": { "application/json": { "schema": { "type": "object", "properties": { + "uid": { + "type": "string" + }, "is_teacher": { "type": "boolean" }, @@ -1494,254 +1671,296 @@ "type": "boolean" } }, - "required": ["is_teacher", "is_admin"] + "required": [ + "uid", + "is_teacher", + "is_admin" + ] } } } }, - "responses": { - "200": { - "description": "User updated successfully" - }, - "404": { - "description": "User not found" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while patching the user" - } - } - }, - "delete": { - "summary": "Delete a user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "404": { - "description": "User not found" - }, - "500": { - "description": "An error occurred while deleting the user" - } + "404": { + "description": "User not found" } } - } - } - }, - "/submissions": { - "get": { - "summary": "Gets the submissions", - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "User ID", - "schema": { - "type": "string" - } - }, - { - "name": "project_id", - "in": "query", - "description": "Project ID", - "schema": { - "type": "integer" + }, + "patch": { + "summary": "Update a user's information", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved a list of submission URLs", + ], + "requestBody": { + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "url": { - "type": "string", - "format": "uri" + "is_teacher": { + "type": "boolean" }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submissions": "array", - "items": { - "type": "string", - "format": "uri" - } - } + "is_admin": { + "type": "boolean" } - } + }, + "required": [ + "is_teacher", + "is_admin" + ] } } } }, - "400": { - "description": "An invalid user or project is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" + "responses": { + "200": { + "description": "User updated successfully" + }, + "404": { + "description": "User not found" + }, + "415": { + "description": "Unsupported Media Type. Expected JSON." + }, + "500": { + "description": "An error occurred while patching the user" + } + } + }, + "delete": { + "summary": "Delete a user", + "parameters": [ + { + "name": "user_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted successfully" + }, + "404": { + "description": "User not found" + }, + "500": { + "description": "An error occurred while deleting the user" + } + } + } + } + } + }, + "/submissions": { + "get": { + "summary": "Gets the submissions", + "parameters": [ + { + "name": "uid", + "in": "query", + "description": "User ID", + "schema": { + "type": "string" + } + }, + { + "name": "project_id", + "in": "query", + "description": "Project ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Successfully retrieved a list of submission URLs", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submissions": "array", + "items": { + "type": "string", + "format": "uri" + } } } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user or project is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } - } - }, - "post": { - "summary": "Posts a new submission to a project", - "requestBody": { - "description": "Form data", + }, + "500": { + "description": "An internal server error occurred", "content": { "application/json": { "schema": { "type": "object", "properties": { - "uid": { + "url": { "type": "string", - "required": true + "format": "uri" }, - "project_id": { - "type": "integer", - "required": true - }, - "files": { - "type": "array", - "items": { - "type": "file" - } + "message": { + "type": "string" } } } } } - }, - "responses": { - "201": { - "description": "Successfully posts the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + } + }, + "post": { + "summary": "Posts a new submission to a project", + "requestBody": { + "description": "Form data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "required": true + }, + "project_id": { + "type": "integer", + "required": true + }, + "files": { + "type": "array", + "items": { + "type": "file" + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Successfully posts the submission and retrieves its data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid user, project or list of files is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid user, project or list of files is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } @@ -1749,56 +1968,56 @@ } } } - }, - "/submissions/{submission_id}": { - "get": { - "summary": "Gets the submission", - "responses": { - "200": { - "description": "Successfully retrieved the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submission": { - "type": "object", - "properties": { - "submission_id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer", - "nullable": true - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + } + }, + "/submissions/{submission_id}": { + "get": { + "summary": "Gets the submission", + "responses": { + "200": { + "description": "Successfully retrieved the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "submission": { + "type": "object", + "properties": { + "submission_id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer", + "nullable": true + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } @@ -1807,246 +2026,247 @@ } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } + } + }, + "patch": { + "summary": "Patches the submission", + "requestBody": { + "description": "The submission data", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grading": { + "type": "integer", + "minimum": 0, + "maximum": 20 + } + } + } + } + } }, - "patch": { - "summary": "Patches the submission", - "requestBody": { - "description": "The submission data", + "responses": { + "200": { + "description": "Successfully patches the submission and retrieves its data", "content": { "application/json": { "schema": { "type": "object", "properties": { - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successfully patches the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "user": { + "type": "string", + "format": "uri" + }, + "project": { + "type": "string", + "format": "uri" + }, + "grading": { + "type": "integer" + }, + "time": { + "type": "string", + "format": "date-time" + }, + "path": { + "type": "string" + }, + "status": { + "type": "boolean" } } } } } } - }, - "400": { - "description": "An invalid submission grading is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "400": { + "description": "An invalid submission grading is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "delete": { - "summary": "Deletes the submission", - "responses": { - "200": { - "description": "Successfully deletes the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "delete": { + "summary": "Deletes the submission", + "responses": { + "200": { + "description": "Successfully deletes the submission", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "404": { + "description": "An invalid submission id is given", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } + } + }, + "500": { + "description": "An internal server error occurred", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri" + }, + "message": { + "type": "string" } } } } } } - }, - "parameters": [ - { - "name": "submission_id", - "in": "path", - "description": "Submission ID", - "required": true, - "schema": { - "type": "integer" - } + } + }, + "parameters": [ + { + "name": "submission_id", + "in": "path", + "description": "Submission ID", + "required": true, + "schema": { + "type": "integer" } - ] - } + } + ] } +} \ No newline at end of file diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index 61b64e61..c1a96248 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -13,7 +13,7 @@ from project import db -from project.models.user import User +from project.models.user import User, Role from project.utils.models.course_utils import is_admin_of_course, \ is_student_of_course, is_teacher_of_course from project.utils.models.project_utils import get_course_of_project, project_visible @@ -29,7 +29,7 @@ def not_allowed(f): """Decorator function to immediately abort the current request and return 403: Forbidden""" @wraps(f) def wrap(*args, **kwargs): - return {"message":"Forbidden action"}, 403 + return {"message": "Forbidden action"}, 403 return wrap @@ -39,20 +39,23 @@ def return_authenticated_user_id(): """ authentication = request.headers.get("Authorization") if not authentication: - abort(make_response(({"message": - "No authorization given, you need an access token to use this API"} - , 401))) + abort( + make_response(( + {"message": + "No authorization given, you need an access token to use this API"}, + 401))) auth_header = {"Authorization": authentication} try: - response = requests.get(AUTHENTICATION_URL, headers=auth_header, timeout=5) + response = requests.get( + AUTHENTICATION_URL, headers=auth_header, timeout=5) except TimeoutError: - abort(make_response(({"message":"Request to Microsoft timed out"} - , 500))) + abort(make_response( + ({"message": "Request to Microsoft timed out"}, 500))) if not response or response.status_code != 200: abort(make_response(({"message": - "An error occured while trying to authenticate your access token"} - , 401))) + "An error occured while trying to authenticate your access token"}, + 401))) user_info = response.json() auth_user_id = user_info["id"] @@ -61,26 +64,30 @@ def return_authenticated_user_id(): except SQLAlchemyError: db.session.rollback() abort(make_response(({"message": - "An unexpected database error occured while fetching the user"}, 500))) + "An unexpected database error occured while fetching the user"}, + 500))) if user: return auth_user_id - is_teacher = False + + # Use the Enum here + role = Role.STUDENT if user_info["jobTitle"] is not None: - is_teacher = True + role = Role.TEACHER # add user if not yet in database try: - new_user = User(uid=auth_user_id, is_teacher=is_teacher, is_admin=False) + new_user = User(uid=auth_user_id, role=role) db.session.add(new_user) db.session.commit() except SQLAlchemyError: db.session.rollback() abort(make_response(({"message": - """An unexpected database error occured + """An unexpected database error occured while creating the user during authentication"""}, 500))) return auth_user_id + def login_required(f): """ This function will check if the person sending a request to the API is logged in @@ -104,7 +111,7 @@ def wrap(*args, **kwargs): if is_admin(auth_user_id): return f(*args, **kwargs) abort(make_response(({"message": - """You are not authorized to perfom this action, + """You are not authorized to perfom this action, only admins are authorized"""}, 403))) return wrap @@ -121,7 +128,7 @@ def wrap(*args, **kwargs): kwargs["teacher_id"] = auth_user_id return f(*args, **kwargs) abort(make_response(({"message": - """You are not authorized to perfom this action, + """You are not authorized to perfom this action, only teachers are authorized"""}, 403))) return wrap @@ -138,7 +145,8 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, kwargs["course_id"]): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -153,10 +161,10 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = kwargs["course_id"] if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, only teachers and course admins are authorized"""}, 403))) return wrap @@ -174,7 +182,7 @@ def wrap(*args, **kwargs): if auth_user_id == user_id: return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not this user"""}, 403))) return wrap @@ -194,7 +202,7 @@ def wrap(*args, **kwargs): if is_teacher_of_course(auth_user_id, course_id): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not the teacher of this project"""}, 403))) return wrap @@ -211,9 +219,9 @@ def wrap(*args, **kwargs): project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"""You are not authorized to perfom this action, + abort(make_response(({"message": """You are not authorized to perfom this action, you are not the teacher or an admin of this project"""}, 403))) return wrap @@ -232,13 +240,15 @@ def wrap(*args, **kwargs): project_id = kwargs["project_id"] course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if is_student_of_course(auth_user_id, course_id) and project_visible(project_id): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap + def authorize_submissions_request(f): """This function will check if the person sending a request to the API is logged in, and either the teacher/admin of the course or the student @@ -251,14 +261,15 @@ def wrap(*args, **kwargs): course_id = get_course_of_project(project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) if (is_student_of_course(auth_user_id, course_id) and project_visible(project_id) - and auth_user_id == request.args.get("uid")): + and auth_user_id == request.args.get("uid")): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -273,9 +284,10 @@ def wrap(*args, **kwargs): course_id = get_course_of_project(project_id) if (is_student_of_course(auth_user_id, course_id) and project_visible(project_id) - and auth_user_id == request.form.get("uid")): + and auth_user_id == request.form.get("uid")): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -290,7 +302,8 @@ def wrap(*args, **kwargs): submission = get_submission(submission_id) if submission.uid == auth_user_id: return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -303,9 +316,10 @@ def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() course_id = get_course_of_submission(kwargs["submission_id"]) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) - abort(make_response(({"message":"You're not authorized to perform this action"}, 403))) + abort(make_response( + ({"message": "You're not authorized to perform this action"}, 403))) return wrap @@ -323,9 +337,8 @@ def wrap(*args, **kwargs): return f(*args, **kwargs) course_id = get_course_of_project(submission.project_id) if (is_teacher_of_course(auth_user_id, course_id) - or is_admin_of_course(auth_user_id, course_id)): + or is_admin_of_course(auth_user_id, course_id)): return f(*args, **kwargs) abort(make_response(({"message": - "You're not authorized to perform this action"} - , 403))) + "You're not authorized to perform this action"}, 403))) return wrap diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index b577e218..3c8d381c 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -2,9 +2,9 @@ from os.path import getsize from re import match -from typing import List, Union +from typing import List, Optional from io import BytesIO -from zipfile import ZipFile, ZIP_DEFLATED +from zipfile import ZipFile, ZIP_DEFLATED, is_zipfile from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage @@ -33,15 +33,24 @@ def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: bool: Are all required files uploaded """ - all_uploaded = True + all_filenames = [] + for file in files: + # Zip + if is_zipfile(file): + with ZipFile(file, "r") as zip_file: + all_filenames += zip_file.namelist() + # File + else: + all_filenames.append(file.filename) + for regex in regexes: - match_found = any(match(regex, file.filename) is not None for file in files) + match_found = any(match(regex, name) is not None for name in all_filenames) if not match_found: - all_uploaded = False - break - return all_uploaded + return False + + return True -def zip_files(name: str, files: List[FileStorage]) -> Union[FileStorage, None]: +def zip_files(name: str, files: List[FileStorage]) -> Optional[FileStorage]: """Zip a dictionary of files Args: diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py index f601c8b3..37cd263c 100644 --- a/backend/project/utils/models/user_utils.py +++ b/backend/project/utils/models/user_utils.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from project import db -from project.models.user import User +from project.models.user import User, Role load_dotenv() API_URL = getenv("API_HOST") @@ -28,9 +28,9 @@ def get_user(user_id): def is_teacher(auth_user_id): """This function checks whether the user with auth_user_id is a teacher""" user = get_user(auth_user_id) - return user.is_teacher + return user.role == Role.TEACHER def is_admin(auth_user_id): """This function checks whether the user with auth_user_id is a teacher""" user = get_user(auth_user_id) - return user.is_admin + return user.role == Role.ADMIN diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 745006a1..01368eb3 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -61,7 +61,7 @@ def create_model_instance(model: DeclarativeMeta, if required_fields is None: required_fields = [] # Check if all non-nullable fields are present in the data - missing_fields = [field for field in required_fields if field not in data] + missing_fields = [field for field in required_fields if field not in data or data[field] == ''] if missing_fields: return {"error": f"Missing required fields: {', '.join(missing_fields)}", diff --git a/backend/requirements.txt b/backend/requirements.txt index 1bbc2e9e..f47e98e6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ flask~=3.0.2 +flask-cors flask-restful flask-sqlalchemy python-dotenv~=1.0.1 @@ -7,3 +8,4 @@ pytest~=8.0.1 SQLAlchemy~=2.0.27 requests>=2.31.0 waitress +flask_swagger_ui diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index 5d13b637..1dc6302f 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -8,6 +8,7 @@ index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) +# Take the key the same as the id, uid can then be used in backend token_dict = { "teacher1":{ "id":"Gunnar", @@ -44,6 +45,41 @@ "admin1":{ "id":"admin_person", "jobTitle":"admin" + }, + # Lowest authorized user to test login requirement + "login": { + "id": "login", + "jobTitle": None + }, + # Student authorization access, associated with valid_... + "student": { + "id": "student", + "jobTitle": None + }, + # Student authorization access, other + "student_other": { + "id": "student_other", + "jobTitle": None + }, + # Teacher authorization access, associated with valid_... + "teacher": { + "id": "teacher", + "jobTitle": "teacher" + }, + # Teacher authorization access, other + "teacher_other": { + "id": "teacher_other", + "jobTitle": "teacher" + }, + # Admin authorization access, associated with valid_... + "admin": { + "id": "admin", + "jobTitle": "admin" + }, + # Admin authorization access, other + "admin_other": { + "id": "admin_other", + "jobTitle": "admin" } } @@ -67,4 +103,4 @@ def get(self): app = Flask(__name__) app.register_blueprint(index_bp) -app.run(debug=True, host='0.0.0.0') +app.run(debug=True, host='0.0.0.0', port=5001) diff --git a/backend/tests.yaml b/backend/tests.yaml index 6238d2ec..dad5289a 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -41,8 +41,10 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here - AUTHENTICATION_URL: http://auth-server:5000 # Use the service name defined in Docker Compose + AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments + DOCS_JSON_PATH: static/OpenAPI_Object.json + DOCS_URL: /docs volumes: - .:/app command: ["pytest"] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cc605602..cb7bc40b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,16 +2,16 @@ from datetime import datetime from zoneinfo import ZoneInfo -import pytest +from pytest import fixture from project.sessionmaker import engine, Session from project.db_in import db from project.models.course import Course -from project.models.user import User +from project.models.user import User,Role from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission, SubmissionStatus -@pytest.fixture +@fixture def db_session(): """Create a new database session for a test. After the test, all changes are rolled back and the session is closed.""" @@ -34,10 +34,10 @@ def db_session(): def users(): """Return a list of users to populate the database""" return [ - User(uid="brinkmann", is_admin=True, is_teacher=True), - User(uid="laermans", is_admin=True, is_teacher=True), - User(uid="student01", is_admin=False, is_teacher=False), - User(uid="student02", is_admin=False, is_teacher=False) + User(uid="brinkmann", role=Role.ADMIN), + User(uid="laermans", role=Role.ADMIN), + User(uid="student01", role=Role.STUDENT), + User(uid="student02", role=Role.STUDENT) ] def courses(): @@ -123,7 +123,22 @@ def submissions(session): ) ] -@pytest.fixture +### AUTHENTICATION & AUTHORIZATION ### +def auth_tokens(): + """Add the authenticated users to the database""" + + return [ + User(uid="login", role=Role.STUDENT), + User(uid="student", role=Role.STUDENT), + User(uid="student_other", role=Role.STUDENT), + User(uid="teacher", role=Role.TEACHER), + User(uid="teacher_other", role=Role.TEACHER), + User(uid="admin", role=Role.ADMIN), + User(uid="admin_other", role=Role.ADMIN) + ] + +### SESSION ### +@fixture def session(): """Create a new database session for a test. After the test, all changes are rolled back and the session is closed.""" @@ -132,6 +147,9 @@ def session(): session = Session() try: + session.add_all(auth_tokens()) + session.commit() + # Populate the database session.add_all(users()) session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 3f044743..dc90b584 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -5,9 +5,11 @@ from datetime import datetime from zoneinfo import ZoneInfo import pytest +from pytest import fixture, FixtureRequest +from flask.testing import FlaskClient from sqlalchemy import create_engine from sqlalchemy.exc import SQLAlchemyError -from project.models.user import User +from project.models.user import User,Role from project.models.course import Course from project.models.course_share_code import CourseShareCode from project import create_app_with_db @@ -15,7 +17,23 @@ from project.models.submission import Submission, SubmissionStatus from project.models.project import Project +### AUTHENTICATEN & AUTHORIZATION ### +@fixture +def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry): + """Add concrete test data""" + # endpoint, parameters, method, token, status + endpoint, parameters, method, *other = request.param + d = { + "course_id": valid_course_entry.course_id + } + + for index, parameter in enumerate(parameters): + endpoint = endpoint.replace(f"@{index}", str(d[parameter])) + + return endpoint, getattr(client, method), *other + +### OTHER ### @pytest.fixture def valid_submission(valid_user_entry, valid_project_entry): """ @@ -47,7 +65,7 @@ def valid_user(): """ return { "uid": "w_student", - "is_teacher": False + "role": Role.STUDENT.name } @pytest.fixture @@ -67,8 +85,7 @@ def valid_admin(): """ return { "uid": "admin_person", - "is_teacher": False, - "is_admin":True + "role": Role.ADMIN, } @pytest.fixture @@ -95,10 +112,10 @@ def valid_user_entries(session): Returns a list of users that are in the database """ users = [ - User(uid="del", is_admin=False, is_teacher=True), - User(uid="pat", is_admin=False, is_teacher=True), - User(uid="u_get", is_admin=False, is_teacher=True), - User(uid="query_user", is_admin=True, is_teacher=False)] + User(uid="del", role=Role.TEACHER), + User(uid="pat", role=Role.TEACHER), + User(uid="u_get", role=Role.TEACHER), + User(uid="query_user", role=Role.ADMIN)] session.add_all(users) session.commit() @@ -149,7 +166,7 @@ def app(): @pytest.fixture def course_teacher_ad(): """A user that's a teacher for testing""" - ad_teacher = User(uid="Gunnar", is_teacher=True, is_admin=True) + ad_teacher = User(uid="Gunnar", role=Role.TEACHER) return ad_teacher @pytest.fixture @@ -199,7 +216,7 @@ def client(app): @pytest.fixture def valid_teacher_entry(session): """A valid teacher for testing that's already in the db""" - teacher = User(uid="Bart", is_teacher=True, is_admin=False) + teacher = User(uid="Bart", role=Role.TEACHER) try: session.add(teacher) session.commit() @@ -217,6 +234,16 @@ def course_no_name(valid_teacher_entry): """A course with no name""" return {"name": "", "teacher": valid_teacher_entry.uid} +@pytest.fixture +def course_empty_name(): + """A course with an empty name""" + return {"name": "", "teacher": "Bart"} + +@pytest.fixture +def invalid_course(): + """An invalid course for testing.""" + return {"invalid": "error"} + @pytest.fixture def valid_course_entry(session, valid_course): """A valid course for testing that's already in the db""" @@ -229,7 +256,7 @@ def valid_course_entry(session, valid_course): def valid_students_entries(session): """Valid students for testing that are already in the db""" students = [ - User(uid=f"student_sel2_{i}", is_teacher=False) + User(uid=f"student_sel2_{i}", role=Role.STUDENT) for i in range(3) ] session.add_all(students) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 5e283ebc..b53ee49f 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -7,6 +7,7 @@ AUTH_TOKEN_TEACHER_2 = "teacher2" AUTH_TOKEN_STUDENT = "student1" + class TestCourseEndpoint: """Class to test the courses API endpoint""" @@ -386,12 +387,13 @@ def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): ### POST COURSE STUDENTS ### ### DELETE COURSE STUDENTS ### - def test_post_courses(self, client, valid_course): + def test_post_courses(self, client, valid_course, invalid_course): """ Test posting a course to the /courses endpoint """ - response = client.post("/courses", json=valid_course, headers={"Authorization":"teacher2"}) + response = client.post("/courses", json=valid_course, + headers={"Authorization": "teacher2"}) assert response.status_code == 201 data = response.json assert data["data"]["name"] == "Sel" @@ -399,9 +401,23 @@ def test_post_courses(self, client, valid_course): # Is reachable using the API get_response = client.get(f"/courses/{data['data']['course_id']}", - headers={"Authorization":"teacher2"}) + headers={"Authorization": "teacher2"}) assert get_response.status_code == 200 + response = client.post( + "/courses?uid=Bart", json=invalid_course, + headers={"Authorization": "teacher2"} + ) # invalid course + assert response.status_code == 400 + + def test_post_no_name(self, client, course_empty_name): + """ + Test posting a course with a blank name + """ + + response = client.post("/courses?uid=Bart", json=course_empty_name, + headers={"Authorization": "teacher2"}) + assert response.status_code == 400 def test_post_courses_course_id_students_and_admins( self, client, valid_course_entry, valid_students_entries): @@ -416,18 +432,18 @@ def test_post_courses_course_id_students_and_admins( response = client.post( sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - json={"students": valid_students}, headers={"Authorization":"teacher2"} + json={"students": valid_students}, headers={"Authorization": "teacher2"} ) assert response.status_code == 403 - def test_get_courses(self, valid_course_entries, client): """ Test all the getters for the courses endpoint """ - response = client.get("/courses", headers={"Authorization":"teacher1"}) + response = client.get( + "/courses", headers={"Authorization": "teacher1"}) assert response.status_code == 200 data = response.json for course in valid_course_entries: @@ -437,13 +453,13 @@ def test_course_delete(self, valid_course_entry, client): """Test all course endpoint related delete functionality""" response = client.delete( - "/courses/" + str(valid_course_entry.course_id), headers={"Authorization":"teacher2"} + "/courses/" + str(valid_course_entry.course_id), headers={"Authorization": "teacher2"} ) assert response.status_code == 200 # Is not reachable using the API get_response = client.get(f"/courses/{valid_course_entry.course_id}", - headers={"Authorization":"teacher2"}) + headers={"Authorization": "teacher2"}) assert get_response.status_code == 404 def test_course_patch(self, valid_course_entry, client): @@ -452,7 +468,7 @@ def test_course_patch(self, valid_course_entry, client): """ response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ "name": "TestTest" - }, headers={"Authorization":"teacher2"}) + }, headers={"Authorization": "teacher2"}) data = response.json assert response.status_code == 200 assert data["data"]["name"] == "TestTest" diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py new file mode 100644 index 00000000..1c468164 --- /dev/null +++ b/backend/tests/endpoints/endpoint.py @@ -0,0 +1,56 @@ +"""Base class for endpoint tests""" + +from typing import List, Tuple +from pytest import param + +def authentication_tests(tests: List[Tuple[str, List[str], List[str]]]) -> List[any]: + """Transform the format to single authentication tests""" + + single_tests = [] + for test in tests: + endpoint, parameters, methods = test + for method in methods: + single_tests.append(param( + (endpoint, parameters, method), + id = f"{endpoint} {method}" + )) + return single_tests + +def authorization_tests(tests: List[Tuple[str, List[str], str, List[str], List[str]]]) -> List[any]: + """Transform the format to single authorization tests""" + + single_tests = [] + for test in tests: + endpoint, parameters, method, allowed_tokens, disallowed_tokens = test + for token in (allowed_tokens + disallowed_tokens): + allowed = token in allowed_tokens + single_tests.append(param( + (endpoint, parameters, method, token, allowed), + id = f"{endpoint} {method} {token} {'allowed' if allowed else 'disallowed'}" + )) + return single_tests + +class TestEndpoint: + """Base class for endpoint tests""" + + def authentication(self, authentication_parameter: Tuple[str, any]): + """Test if the authentication for the given enpoint works""" + + endpoint, method = authentication_parameter + + response = method(endpoint) + assert response.status_code == 401 + + response = method(endpoint, headers = {"Authorization": "0123456789"}) + assert response.status_code == 401 + + response = method(endpoint, headers = {"Authorization": "login"}) + assert response.status_code != 401 + + def authorization(self, auth_parameter: Tuple[str, any, str, bool]): + """Test if the authorization for the given endpoint works""" + + endpoint, method, token, allowed = auth_parameter + + response = method(endpoint, headers = {"Authorization": token}) + assert allowed == (response.status_code != 403) diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index a900bb84..1354b4be 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -59,12 +59,11 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" assert data["data"] == { - "id": submission.submission_id, + "id": f"{API_HOST}/submissions/{submission.submission_id}", "user": f"{API_HOST}/users/student01", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 16, "time": "Thu, 14 Mar 2024 12:00:00 GMT", - "path": "/submissions/1", "status": 'SUCCESS' } @@ -117,12 +116,11 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" assert data["url"] == f"{API_HOST}/submissions/{submission.submission_id}" assert data["data"] == { - "id": submission.submission_id, + "id": f"{API_HOST}/submissions/{submission.submission_id}", "user": f"{API_HOST}/users/student02", "project": f"{API_HOST}/projects/{project.project_id}", "grading": 20, "time": 'Thu, 14 Mar 2024 23:59:59 GMT', - "path": "/submissions/2", "status": 'FAIL' } @@ -144,7 +142,7 @@ def test_delete_submission_correct(self, client: FlaskClient, session: Session): headers={"Authorization":"student01"}) data = response.json assert response.status_code == 200 - assert data["message"] == f"Submission (submission_id={submission.submission_id}) deleted" + assert data["message"] == "Resource deleted successfully" assert submission.submission_id not in list(map( lambda s: s.submission_id, session.query(Submission).all() )) diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index c6044db2..7d3a0c39 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -11,7 +11,7 @@ import pytest from sqlalchemy.orm import sessionmaker from sqlalchemy import create_engine -from project.models.user import User +from project.models.user import User,Role from project.db_in import db from tests import db_url @@ -24,12 +24,12 @@ def user_db_session(): db.metadata.create_all(engine) session = Session() session.add_all( - [User(uid="del", is_admin=False, is_teacher=True), - User(uid="pat", is_admin=False, is_teacher=True), - User(uid="u_get", is_admin=False, is_teacher=True), - User(uid="query_user", is_admin=True, is_teacher=False) - ] - ) + [User(uid="del", role=Role.TEACHER), + User(uid="pat", role=Role.TEACHER), + User(uid="u_get", role=Role.TEACHER), + User(uid="query_user", role=Role.ADMIN) + ] + ) session.commit() yield session session.rollback() @@ -120,38 +120,50 @@ def test_get_one_user_wrong_authentication(self, client, valid_user_entry): assert response.status_code == 401 def test_patch_user_not_authorized(self, client, valid_admin_entry, valid_user_entry): - """Test trying to patch a user without authorization""" - new_is_teacher = not valid_user_entry.is_teacher + """Test updating a user.""" + if valid_user_entry.role == Role.TEACHER: + new_role = Role.ADMIN + if valid_user_entry.role == Role.ADMIN: + new_role = Role.STUDENT + else: + new_role = Role.TEACHER + new_role = new_role.name response = client.patch(f"/users/{valid_user_entry.uid}", json={ - 'is_teacher': new_is_teacher, - 'is_admin': not valid_user_entry.is_admin + 'role': new_role }, headers={"Authorization":"student01"}) assert response.status_code == 403 # Patching a user is not allowed as a not-admin def test_patch_user(self, client, valid_admin_entry, valid_user_entry): """Test updating a user.""" - new_is_teacher = not valid_user_entry.is_teacher - + if valid_user_entry.role == Role.TEACHER: + new_role = Role.ADMIN + if valid_user_entry.role == Role.ADMIN: + new_role = Role.STUDENT + else: + new_role = Role.TEACHER + new_role = new_role.name response = client.patch(f"/users/{valid_user_entry.uid}", json={ - 'is_teacher': new_is_teacher, - 'is_admin': not valid_user_entry.is_admin + 'role': new_role }, headers={"Authorization":"admin1"}) assert response.status_code == 200 def test_patch_non_existent(self, client, valid_admin_entry): """Test updating a non-existent user.""" response = client.patch("/users/-20", json={ - 'is_teacher': False, - 'is_admin': True + 'role': Role.TEACHER.name }, headers={"Authorization":"admin1"}) assert response.status_code == 404 def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) - valid_user_form["is_teacher"] = not valid_user_form["is_teacher"] + if valid_user_form["role"] == Role.TEACHER.name: + valid_user_form["role"] = Role.STUDENT.name + else: + valid_user_form["role"] = Role.TEACHER.name + response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form, headers={"Authorization":"admin1"}) assert response.status_code == 415 @@ -159,12 +171,11 @@ def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?is_admin=true&is_teacher=false", + response = client.get("/users?role=ADMIN", headers={"Authorization":"teacher1"}) assert response.status_code == 200 # Check that the response contains only the user that matches the query users = response.json["data"] for user in users: - assert user["is_admin"] is True - assert user["is_teacher"] is False + assert Role[user["role"]] == Role.ADMIN diff --git a/backend/tests/models/user_test.py b/backend/tests/models/user_test.py index 8a026711..d607ebad 100644 --- a/backend/tests/models/user_test.py +++ b/backend/tests/models/user_test.py @@ -3,32 +3,32 @@ from pytest import raises, mark from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError -from project.models.user import User +from project.models.user import User,Role class TestUserModel: """Class to test the User model""" def test_create_user(self, session: Session): """Test if a user can be created""" - user = User(uid="user01", is_teacher=False, is_admin=False) + user = User(uid="user01", role=Role.STUDENT) session.add(user) session.commit() assert session.get(User, "user01") is not None - assert session.query(User).count() == 5 + assert session.query(User).count() == 12 def test_query_user(self, session: Session): """Test if a user can be queried""" - assert session.query(User).count() == 4 + assert session.query(User).count() == 11 teacher = session.query(User).filter_by(uid="brinkmann").first() assert teacher is not None - assert teacher.is_teacher + assert teacher.role == Role.ADMIN def test_update_user(self, session: Session): """Test if a user can be updated""" student = session.query(User).filter_by(uid="student01").first() - student.is_admin = True + student.role = Role.ADMIN session.commit() - assert session.get(User, "student01").is_admin + assert session.get(User, "student01").role == Role.ADMIN def test_delete_user(self, session: Session): """Test if a user can be deleted""" @@ -36,7 +36,7 @@ def test_delete_user(self, session: Session): session.delete(user) session.commit() assert session.get(User, user.uid) is None - assert session.query(User).count() == 3 + assert session.query(User).count() == 10 @mark.parametrize("property_name", ["uid"]) def test_property_not_nullable(self, session: Session, property_name: str): diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4fc8aea3..8467e285 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,6 +30,10 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", + "react-i18next": "^14.1.0", "typescript": "^5.2.2", "vite": "^5.1.0" } @@ -2731,6 +2735,15 @@ "node": ">=10" } }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -4002,6 +4015,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dev": true, + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -4025,6 +4047,47 @@ "node": ">=8.12.0" } }, + "node_modules/i18next": { + "version": "23.10.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.1.tgz", + "integrity": "sha512-NDiIzFbcs3O9PXpfhkjyf7WdqFn5Vq6mhzhtkXzj51aOcNuPNcTwuYNuXCpHsanZGHlHKL35G7huoFeVic1hng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz", + "integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.5.0.tgz", + "integrity": "sha512-Z/aQsGZk1gSxt2/DztXk92DuDD20J+rNudT7ZCdTrNOiK8uQppfvdjq9+DFQfpAnFPn3VZS+KQIr1S/W1KxhpQ==", + "dev": true, + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4780,6 +4843,26 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -5186,6 +5269,28 @@ "react": "^18.2.0" } }, + "node_modules/react-i18next": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-14.1.0.tgz", + "integrity": "sha512-3KwX6LHpbvGQ+sBEntjV4sYW3Zovjjl3fpoHbUwSgFHf0uRBcbeCBLR5al6ikncI5+W0EFb71QXZmfop+J6NrQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -5865,6 +5970,12 @@ "node": ">= 4.0.0" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true + }, "node_modules/ts-api-utils": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", @@ -6089,6 +6200,31 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4645a37b..dfb8c6fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -34,6 +34,10 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", + "i18next": "^23.10.1", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", + "react-i18next": "^14.1.0", "typescript": "^5.2.2", "vite": "^5.1.0" } diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json new file mode 100644 index 00000000..1447580c --- /dev/null +++ b/frontend/public/locales/en/translation.json @@ -0,0 +1,7 @@ +{ + "homepage": "Homepage", + "myProjects": "My Projects", + "myCourses": "My Courses", + "login": "Login", + "home": "Home" + } \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json new file mode 100644 index 00000000..c852df96 --- /dev/null +++ b/frontend/public/locales/nl/translation.json @@ -0,0 +1,7 @@ +{ + "homepage": "Homepage", + "myProjects": "Mijn Projecten", + "myCourses": "Mijn Vakken", + "login": "Login", + "home": "Home" + } \ No newline at end of file diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 860086fd..8595e6d4 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -7,12 +7,14 @@ import { Typography, } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; +import { useTranslation } from "react-i18next"; /** * The header component for the application that will be rendered at the top of the page. * @returns - The header component */ export function Header(): JSX.Element { + const { t } = useTranslation(); return ( @@ -21,7 +23,7 @@ export function Header(): JSX.Element { - Home + {t('home')} diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 00000000..98055d4a --- /dev/null +++ b/frontend/src/i18n.js @@ -0,0 +1,19 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +i18n + .use(Backend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: 'en', + debug: true, + + interpolation: { + escapeValue: false, + } + }); + +export default i18n; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3d7150da..9b684efc 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.tsx' import './index.css' +import './i18n' ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 80610e7c..344fb124 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,12 +1,14 @@ +import { useTranslation } from "react-i18next"; /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { + const { t } = useTranslation(); return (
-

HomePage

+

{t('homepage')}

); } From acc6dff2665c0bc4d2312d7bfe3888760154308d Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 30 Mar 2024 22:03:06 +0100 Subject: [PATCH 230/377] courses get finished --- backend/tests/endpoints/conftest.py | 70 +- .../tests/endpoints/course/courses_test.py | 777 +++++++++--------- .../tests/endpoints/course/share_link_test.py | 8 +- backend/tests/endpoints/project_test.py | 12 +- backend/tests/endpoints/submissions_test.py | 2 +- 5 files changed, 443 insertions(+), 426 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index dc90b584..389b4293 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,9 +1,10 @@ -""" Configuration for pytest, Flask, and the test client.""" +"""Pytest fixtures""" import tempfile import os from datetime import datetime from zoneinfo import ZoneInfo +from typing import Tuple import pytest from pytest import fixture, FixtureRequest from flask.testing import FlaskClient @@ -19,8 +20,9 @@ ### AUTHENTICATEN & AUTHORIZATION ### @fixture -def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry): +def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry) -> Tuple: """Add concrete test data""" + # endpoint, parameters, method, token, status endpoint, parameters, method, *other = request.param @@ -33,6 +35,38 @@ def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry): return endpoint, getattr(client, method), *other +### USERS ### +@fixture +def valid_teacher_entry(session): + """A valid teacher for testing that's already in the db""" + return session.get(User, "teacher") + +### COURSES ### +@fixture +def valid_course_entries(session, valid_teacher_entry): + """A valid course for testing that's already in the db""" + courses = [Course(name=f"SEL{i}", teacher=valid_teacher_entry.uid) for i in range(1, 3)] + session.add_all(courses) + session.commit() + return courses + +@fixture +def valid_course(valid_teacher_entry): + """A valid course json form""" + return {"name": "SEL", "ufora_id": "C003784A_2023", "teacher": valid_teacher_entry.uid} + +@fixture +def valid_course_entry(session, valid_course): + """A valid course for testing that's already in the db""" + course = Course(**valid_course) + session.add(course) + session.commit() + return course + + + + + ### OTHER ### @pytest.fixture def valid_submission(valid_user_entry, valid_project_entry): @@ -213,22 +247,6 @@ def client(app): with app.app_context(): yield client -@pytest.fixture -def valid_teacher_entry(session): - """A valid teacher for testing that's already in the db""" - teacher = User(uid="Bart", role=Role.TEACHER) - try: - session.add(teacher) - session.commit() - except SQLAlchemyError: - session.rollback() - return teacher - -@pytest.fixture -def valid_course(valid_teacher_entry): - """A valid course json form""" - return {"name": "Sel", "ufora_id": "C003784A_2023", "teacher": valid_teacher_entry.uid} - @pytest.fixture def course_no_name(valid_teacher_entry): """A course with no name""" @@ -244,14 +262,6 @@ def invalid_course(): """An invalid course for testing.""" return {"invalid": "error"} -@pytest.fixture -def valid_course_entry(session, valid_course): - """A valid course for testing that's already in the db""" - course = Course(**valid_course) - session.add(course) - session.commit() - return course - @pytest.fixture def valid_students_entries(session): """Valid students for testing that are already in the db""" @@ -263,14 +273,6 @@ def valid_students_entries(session): session.commit() return students -@pytest.fixture -def valid_course_entries(session, valid_teacher_entry): - """A valid course for testing that's already in the db""" - courses = [Course(name=f"Sel{i}", teacher=valid_teacher_entry.uid) for i in range(3)] - session.add_all(courses) - session.commit() - return courses - @pytest.fixture def share_code_admin(session, valid_course_entry): """A course with share codes for testing.""" diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index b53ee49f..1ec197ea 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,56 +1,69 @@ """Tests the courses API endpoint""" +from typing import Tuple, List +from pytest import mark from flask.testing import FlaskClient +from tests.endpoints.endpoint import TestEndpoint, authentication_tests, authorization_tests +from project.models.course import Course -AUTH_TOKEN_BAD = "" -AUTH_TOKEN_TEACHER_1 = "teacher1" -AUTH_TOKEN_TEACHER_2 = "teacher2" -AUTH_TOKEN_STUDENT = "student1" +class TestCourseEndpoint(TestEndpoint): + """Class to test the courses API endpoint""" + ### AUTHENTICATION & AUTHORIZATION ### + # Where is login required + # (endpoint, parameters, methods) + authentication = authentication_tests([ + ("/courses", [], ["get", "post"]) + ]) + + # Who can access what + # (endpoint, parameters, method, allowed, disallowed) + authorization = authorization_tests([ + ("/courses", [], "get", ["student", "teacher", "admin"], []), + ("/courses", [], "post", ["teacher"], ["student", "admin"]) + ]) + + @mark.parametrize("auth_test", authentication, indirect=True) + def test_authentication(self, auth_test: Tuple[str, any]): + """Test the authentication""" + super().authentication(auth_test) + + @mark.parametrize("auth_test", authorization, indirect=True) + def test_authorization(self, auth_test: Tuple[str, any, str, bool]): + """Test the authorization""" + super().authorization(auth_test) -class TestCourseEndpoint: - """Class to test the courses API endpoint""" - ### GET COURSES ### - def test_get_courses_not_authenticated(self, client: FlaskClient): - """Test getting courses when not authenticated""" - response = client.get("/courses") - assert response.status_code == 401 - def test_get_courses_bad_authentication_token(self, client: FlaskClient): - """Test getting courses for a bad authentication token""" - response = client.get("/courses", headers = {"Authorization": AUTH_TOKEN_BAD}) - assert response.status_code == 401 - def test_get_courses_all(self, client: FlaskClient, valid_course_entries): + + ### GET COURSES ### + def test_get_courses_all(self, client: FlaskClient, valid_course_entries: List[Course]): """Test getting all courses""" - response = client.get("/courses", headers = {"Authorization": AUTH_TOKEN_STUDENT}) + response = client.get("/courses", headers = {"Authorization": "student"}) assert response.status_code == 200 data = [course["name"] for course in response.json["data"]] assert all(course.name in data for course in valid_course_entries) def test_get_courses_wrong_parameter(self, client: FlaskClient): """Test getting courses for a wrong parameter""" - response = client.get( - "/courses?parameter=0", - headers = {"Authorization": AUTH_TOKEN_STUDENT} - ) + response = client.get("/courses?parameter=0", headers = {"Authorization": "student"}) assert response.status_code == 400 def test_get_courses_wrong_name(self, client: FlaskClient): """Test getting courses for a wrong course name""" response = client.get( "/courses?name=no_name", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert response.json["data"] == [] - def test_get_courses_name(self, client: FlaskClient, valid_course_entry): + def test_get_courses_name(self, client: FlaskClient, valid_course_entry: Course): """Test getting courses for a given course name""" response = client.get( f"/courses?name={valid_course_entry.name}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] @@ -59,16 +72,16 @@ def test_get_courses_wrong_ufora_id(self, client: FlaskClient): """Test getting courses for a wrong ufora_id""" response = client.get( "/courses?ufora_id=no_ufora_id", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert response.json["data"] == [] - def test_get_courses_ufora_id(self, client: FlaskClient, valid_course_entry): + def test_get_courses_ufora_id(self, client: FlaskClient, valid_course_entry: Course): """Test getting courses for a given ufora_id""" response = client.get( f"/courses?ufora_id={valid_course_entry.ufora_id}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert valid_course_entry.ufora_id in \ @@ -78,397 +91,399 @@ def test_get_courses_wrong_teacher(self, client: FlaskClient): """Test getting courses for a wrong teacher""" response = client.get( "/courses?teacher=no_teacher", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert response.json["data"] == [] - def test_get_courses_teacher(self, client: FlaskClient, valid_course_entry): + def test_get_courses_teacher(self, client: FlaskClient, valid_course_entry: Course): """Test getting courses for a given teacher""" response = client.get( f"/courses?teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert valid_course_entry.teacher in [course["teacher"] for course in response.json["data"]] - def test_get_courses_name_ufora_id(self, client: FlaskClient, valid_course_entry): + def test_get_courses_name_ufora_id(self, client: FlaskClient, valid_course_entry: Course): """Test getting courses for a given course name and ufora_id""" response = client.get( f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] - def test_get_courses_name_teacher(self, client: FlaskClient, valid_course_entry): + def test_get_courses_name_teacher(self, client: FlaskClient, valid_course_entry: Course): """Test getting courses for a given course name and teacher""" response = client.get( f"/courses?name={valid_course_entry.name}&teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] - def test_get_courses_ufora_id_teacher(self, client: FlaskClient, valid_course_entry): + def test_get_courses_ufora_id_teacher(self, client: FlaskClient, valid_course_entry: Course): """Test getting courses for a given ufora_id and teacher""" response = client.get( f"/courses?ufora_id={valid_course_entry.ufora_id}&teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] - def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, valid_course_entry): + def test_get_courses_name_ufora_id_teacher( + self, client: FlaskClient, valid_course_entry: Course + ): """Test getting courses for a given name, ufora_id and teacher""" response = client.get( f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}" \ f"&teacher={valid_course_entry.teacher}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} + headers = {"Authorization": "student"} ) assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] - ### POST COURSES ### - def test_post_courses_not_authenticated(self, client: FlaskClient): - """Test posting a course when not authenticated""" - response = client.post("/courses") - assert response.status_code == 401 - - def test_post_courses_bad_authentication_token(self, client: FlaskClient): - """Test posting a course when given a bad authentication token""" - response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_BAD}) - assert response.status_code == 401 - - def test_post_courses_no_authorization(self, client: FlaskClient): - """Test posting a course when not having the correct authorization""" - response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_STUDENT}) - assert response.status_code == 403 - - def test_post_courses_wrong_name_type(self, client: FlaskClient): - """Test posting a course where the name does not have the correct type""" - response = client.post( - "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, - json = { - "name": 0, - "ufora_id": "test" - } - ) - assert response.status_code == 400 - - def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): - """Test posting a course where the ufora_id does not have the correct type""" - response = client.post( - "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, - json = { - "name": "test", - "ufora_id": 0 - } - ) - assert response.status_code == 400 - - def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_entry): - """Test posting a course where a field that doesn't occur in the model is given""" - response = client.post( - "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, - json = { - "name": "test", - "ufora_id": "test", - "teacher": valid_teacher_entry.uid - } - ) - assert response.status_code == 400 - - def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): - """Test posting a course""" - response = client.post( - "/courses", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - json = { - "name": "test", - "ufora_id": "test" - } - ) - assert response.status_code == 201 - response = client.get( - "/courses?name=test", - headers = {"Authorization": AUTH_TOKEN_STUDENT} - ) - assert response.status_code == 200 - data = response.json["data"] - assert data[0]["ufora_id"] == "test" - assert data[0]["teacher"] == valid_teacher_entry.uid # uid corresponds with AUTH_TOKEN - - ### GET COURSE ### - def test_get_course_not_authenticated(self, client: FlaskClient, valid_course_entry): - """Test getting a course while not authenticated""" - response = client.get(f"/courses/{valid_course_entry.course_id}") - assert response.status_code == 401 - - def test_get_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): - """Test getting a course while using a bad authentication token""" - response = client.get( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_BAD} - ) - assert response.status_code == 401 - - def test_get_course_wrong_course_id(self, client: FlaskClient): - """Test getting a non existing course by given a wrong course_id""" - response = client.get( - "/courses/0", - headers = {"Authorization": AUTH_TOKEN_STUDENT} - ) - assert response.status_code == 404 - - def test_get_course_correct(self, client: FlaskClient, valid_course_entry): - """Test getting a course""" - response = client.get( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} - ) - assert response.status_code == 200 - data = response.json["data"] - assert data["name"] == valid_course_entry.name - assert data["ufora_id"] == valid_course_entry.ufora_id - assert data["teacher"] == valid_course_entry.teacher - - ### PATCH COURSE ### - def test_patch_course_not_authenticated(self, client: FlaskClient, valid_course_entry): - """Test patching a course while not authenticated""" - response = client.patch(f"/courses/{valid_course_entry.course_id}") - assert response.status_code == 401 - - def test_patch_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): - """Test patching a course while using a bad authentication token""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_BAD} - ) - assert response.status_code == 401 - - def test_patch_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): - """Test patching a course as a student""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} - ) - assert response.status_code == 403 - - def test_patch_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): - """Test patching a course as a teacher of a different course""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_1} - ) - assert response.status_code == 403 - - def test_patch_course_wrong_course_id(self, client: FlaskClient, valid_course_entry): - """Test patching a course that does not exist""" - response = client.patch( - "/courses/0", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - ) - assert response.status_code == 404 - - def test_patch_course_wrong_name_type(self, client: FlaskClient, valid_course_entry): - """Test patching a course given a wrong type for the course name""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - json = {"name": 0} - ) - assert response.status_code == 400 - - def test_patch_course_ufora_id_type(self, client: FlaskClient, valid_course_entry): - """Test patching a course given a wrong type for the ufora_id""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - json = {"ufora_id": 0} - ) - assert response.status_code == 400 - - def test_patch_course_wrong_teacher_type(self, client: FlaskClient, valid_course_entry): - """Test patching a course given a wrong type for the teacher""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - json = {"teacher": 0} - ) - assert response.status_code == 400 - - def test_patch_course_wrong_teacher(self, client: FlaskClient, valid_course_entry): - """Test patching a course given a teacher that does not exist""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - json = {"teacher": "no_teacher"} - ) - assert response.status_code == 400 - - def test_patch_course_incorrect_field(self, client: FlaskClient, valid_course_entry): - """Test patching a course with a field that doesn't occur in the course model""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - json = {"field": 0} - ) - assert response.status_code == 400 - - def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): - """Test patching a course""" - response = client.patch( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - json = {"name": "test"} - ) - assert response.status_code == 200 - assert response.json["data"]["name"] == "test" - - ### DELETE COURSE ### - def test_delete_course_not_authenticated(self, client: FlaskClient, valid_course_entry): - """Test deleting a course while not authenticated""" - response = client.delete(f"/courses/{valid_course_entry.course_id}") - assert response.status_code == 401 - - def test_delete_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): - """Test deleting a course while using a bad authentication token""" - response = client.delete( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_BAD} - ) - assert response.status_code == 401 - - def test_delete_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): - """Test deleting a course as a student""" - response = client.delete( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_STUDENT} - ) - assert response.status_code == 403 - - def test_delete_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): - """Test deleting a course as a teacher of a different course""" - response = client.delete( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_1} - ) - assert response.status_code == 403 - - def test_delete_course_wrong_course_id(self, client: FlaskClient): - """Test deleting a course that does not exist""" - response = client.delete( - "/courses/0", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - ) - assert response.status_code == 404 - - def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): - """Test deleting a course""" - response = client.delete( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - ) - assert response.status_code == 200 - response = client.get( - f"/courses/{valid_course_entry.course_id}", - headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - ) - assert response.status_code == 404 - - ### GET COURSE ADMINS ### - ### POST COURSE ADMINS ### - ### DELETE COURSE ADMINS ### - ### GET COURSE STUDENTS ### - ### POST COURSE STUDENTS ### - ### DELETE COURSE STUDENTS ### - - def test_post_courses(self, client, valid_course, invalid_course): - """ - Test posting a course to the /courses endpoint - """ - - response = client.post("/courses", json=valid_course, - headers={"Authorization": "teacher2"}) - assert response.status_code == 201 - data = response.json - assert data["data"]["name"] == "Sel" - assert data["data"]["teacher"] == valid_course["teacher"] - - # Is reachable using the API - get_response = client.get(f"/courses/{data['data']['course_id']}", - headers={"Authorization": "teacher2"}) - assert get_response.status_code == 200 - - response = client.post( - "/courses?uid=Bart", json=invalid_course, - headers={"Authorization": "teacher2"} - ) # invalid course - assert response.status_code == 400 - - def test_post_no_name(self, client, course_empty_name): - """ - Test posting a course with a blank name - """ - - response = client.post("/courses?uid=Bart", json=course_empty_name, - headers={"Authorization": "teacher2"}) - assert response.status_code == 400 - - def test_post_courses_course_id_students_and_admins( - self, client, valid_course_entry, valid_students_entries): - """ - Test posting to courses/course_id/students and admins - """ - - # Posting to /courses/course_id/students and admins test - sel2_students_link = "/courses/" + str(valid_course_entry.course_id) - - valid_students = [s.uid for s in valid_students_entries] - - response = client.post( - sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - json={"students": valid_students}, headers={"Authorization": "teacher2"} - ) - - assert response.status_code == 403 - - def test_get_courses(self, valid_course_entries, client): - """ - Test all the getters for the courses endpoint - """ - - response = client.get( - "/courses", headers={"Authorization": "teacher1"}) - assert response.status_code == 200 - data = response.json - for course in valid_course_entries: - assert course.name in [c["name"] for c in data["data"]] - - def test_course_delete(self, valid_course_entry, client): - """Test all course endpoint related delete functionality""" - - response = client.delete( - "/courses/" + str(valid_course_entry.course_id), headers={"Authorization": "teacher2"} - ) - assert response.status_code == 200 - - # Is not reachable using the API - get_response = client.get(f"/courses/{valid_course_entry.course_id}", - headers={"Authorization": "teacher2"}) - assert get_response.status_code == 404 - - def test_course_patch(self, valid_course_entry, client): - """ - Test the patching of a course - """ - response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ - "name": "TestTest" - }, headers={"Authorization": "teacher2"}) - data = response.json - assert response.status_code == 200 - assert data["data"]["name"] == "TestTest" + # ### POST COURSES ### + # def test_post_courses_not_authenticated(self, client: FlaskClient): + # """Test posting a course when not authenticated""" + # response = client.post("/courses") + # assert response.status_code == 401 + + # def test_post_courses_bad_authentication_token(self, client: FlaskClient): + # """Test posting a course when given a bad authentication token""" + # response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_BAD}) + # assert response.status_code == 401 + + # def test_post_courses_no_authorization(self, client: FlaskClient): + # """Test posting a course when not having the correct authorization""" + # response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_STUDENT}) + # assert response.status_code == 403 + + # def test_post_courses_wrong_name_type(self, client: FlaskClient): + # """Test posting a course where the name does not have the correct type""" + # response = client.post( + # "/courses", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, + # json = { + # "name": 0, + # "ufora_id": "test" + # } + # ) + # assert response.status_code == 400 + + # def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): + # """Test posting a course where the ufora_id does not have the correct type""" + # response = client.post( + # "/courses", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, + # json = { + # "name": "test", + # "ufora_id": 0 + # } + # ) + # assert response.status_code == 400 + + # def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_entry): + # """Test posting a course where a field that doesn't occur in the model is given""" + # response = client.post( + # "/courses", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, + # json = { + # "name": "test", + # "ufora_id": "test", + # "teacher": valid_teacher_entry.uid + # } + # ) + # assert response.status_code == 400 + + # def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): + # """Test posting a course""" + # response = client.post( + # "/courses", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + # json = { + # "name": "test", + # "ufora_id": "test" + # } + # ) + # assert response.status_code == 201 + # response = client.get( + # "/courses?name=test", + # headers = {"Authorization": AUTH_TOKEN_STUDENT} + # ) + # assert response.status_code == 200 + # data = response.json["data"] + # assert data[0]["ufora_id"] == "test" + # assert data[0]["teacher"] == valid_teacher_entry.uid # uid corresponds with AUTH_TOKEN + + # ### GET COURSE ### + # def test_get_course_not_authenticated(self, client: FlaskClient, valid_course_entry): + # """Test getting a course while not authenticated""" + # response = client.get(f"/courses/{valid_course_entry.course_id}") + # assert response.status_code == 401 + + # def test_get_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): + # """Test getting a course while using a bad authentication token""" + # response = client.get( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_BAD} + # ) + # assert response.status_code == 401 + + # def test_get_course_wrong_course_id(self, client: FlaskClient): + # """Test getting a non existing course by given a wrong course_id""" + # response = client.get( + # "/courses/0", + # headers = {"Authorization": AUTH_TOKEN_STUDENT} + # ) + # assert response.status_code == 404 + + # def test_get_course_correct(self, client: FlaskClient, valid_course_entry): + # """Test getting a course""" + # response = client.get( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_STUDENT} + # ) + # assert response.status_code == 200 + # data = response.json["data"] + # assert data["name"] == valid_course_entry.name + # assert data["ufora_id"] == valid_course_entry.ufora_id + # assert data["teacher"] == valid_course_entry.teacher + + # ### PATCH COURSE ### + # def test_patch_course_not_authenticated(self, client: FlaskClient, valid_course_entry): + # """Test patching a course while not authenticated""" + # response = client.patch(f"/courses/{valid_course_entry.course_id}") + # assert response.status_code == 401 + + # def test_patch_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): + # """Test patching a course while using a bad authentication token""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_BAD} + # ) + # assert response.status_code == 401 + + # def test_patch_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): + # """Test patching a course as a student""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_STUDENT} + # ) + # assert response.status_code == 403 + + # def test_patch_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): + # """Test patching a course as a teacher of a different course""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_1} + # ) + # assert response.status_code == 403 + + # def test_patch_course_wrong_course_id(self, client: FlaskClient, valid_course_entry): + # """Test patching a course that does not exist""" + # response = client.patch( + # "/courses/0", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + # ) + # assert response.status_code == 404 + + # def test_patch_course_wrong_name_type(self, client: FlaskClient, valid_course_entry): + # """Test patching a course given a wrong type for the course name""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + # json = {"name": 0} + # ) + # assert response.status_code == 400 + + # def test_patch_course_ufora_id_type(self, client: FlaskClient, valid_course_entry): + # """Test patching a course given a wrong type for the ufora_id""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + # json = {"ufora_id": 0} + # ) + # assert response.status_code == 400 + + # def test_patch_course_wrong_teacher_type(self, client: FlaskClient, valid_course_entry): + # """Test patching a course given a wrong type for the teacher""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + # json = {"teacher": 0} + # ) + # assert response.status_code == 400 + + # def test_patch_course_wrong_teacher(self, client: FlaskClient, valid_course_entry): + # """Test patching a course given a teacher that does not exist""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + # json = {"teacher": "no_teacher"} + # ) + # assert response.status_code == 400 + + # def test_patch_course_incorrect_field(self, client: FlaskClient, valid_course_entry): + # """Test patching a course with a field that doesn't occur in the course model""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + # json = {"field": 0} + # ) + # assert response.status_code == 400 + + # def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): + # """Test patching a course""" + # response = client.patch( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, + # json = {"name": "test"} + # ) + # assert response.status_code == 200 + # assert response.json["data"]["name"] == "test" + + # ### DELETE COURSE ### + # def test_delete_course_not_authenticated(self, client: FlaskClient, valid_course_entry): + # """Test deleting a course while not authenticated""" + # response = client.delete(f"/courses/{valid_course_entry.course_id}") + # assert response.status_code == 401 + + # def test_delete_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): + # """Test deleting a course while using a bad authentication token""" + # response = client.delete( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_BAD} + # ) + # assert response.status_code == 401 + + # def test_delete_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): + # """Test deleting a course as a student""" + # response = client.delete( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_STUDENT} + # ) + # assert response.status_code == 403 + + # def test_delete_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): + # """Test deleting a course as a teacher of a different course""" + # response = client.delete( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_1} + # ) + # assert response.status_code == 403 + + # def test_delete_course_wrong_course_id(self, client: FlaskClient): + # """Test deleting a course that does not exist""" + # response = client.delete( + # "/courses/0", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + # ) + # assert response.status_code == 404 + + # def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): + # """Test deleting a course""" + # response = client.delete( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + # ) + # assert response.status_code == 200 + # response = client.get( + # f"/courses/{valid_course_entry.course_id}", + # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} + # ) + # assert response.status_code == 404 + + # ### GET COURSE ADMINS ### + # ### POST COURSE ADMINS ### + # ### DELETE COURSE ADMINS ### + # ### GET COURSE STUDENTS ### + # ### POST COURSE STUDENTS ### + # ### DELETE COURSE STUDENTS ### + + # def test_post_courses(self, client, valid_course, invalid_course): + # """ + # Test posting a course to the /courses endpoint + # """ + + # response = client.post("/courses", json=valid_course, + # headers={"Authorization": "teacher2"}) + # assert response.status_code == 201 + # data = response.json + # assert data["data"]["name"] == "Sel" + # assert data["data"]["teacher"] == valid_course["teacher"] + + # # Is reachable using the API + # get_response = client.get(f"/courses/{data['data']['course_id']}", + # headers={"Authorization": "teacher2"}) + # assert get_response.status_code == 200 + + # response = client.post( + # "/courses?uid=Bart", json=invalid_course, + # headers={"Authorization": "teacher2"} + # ) # invalid course + # assert response.status_code == 400 + + # def test_post_no_name(self, client, course_empty_name): + # """ + # Test posting a course with a blank name + # """ + + # response = client.post("/courses?uid=Bart", json=course_empty_name, + # headers={"Authorization": "teacher2"}) + # assert response.status_code == 400 + + # def test_post_courses_course_id_students_and_admins( + # self, client, valid_course_entry, valid_students_entries): + # """ + # Test posting to courses/course_id/students and admins + # """ + + # # Posting to /courses/course_id/students and admins test + # sel2_students_link = "/courses/" + str(valid_course_entry.course_id) + + # valid_students = [s.uid for s in valid_students_entries] + + # response = client.post( + # sel2_students_link + f"/students?uid={valid_course_entry.teacher}", + # json={"students": valid_students}, headers={"Authorization": "teacher2"} + # ) + + # assert response.status_code == 403 + + # def test_get_courses(self, valid_course_entries, client): + # """ + # Test all the getters for the courses endpoint + # """ + + # response = client.get( + # "/courses", headers={"Authorization": "teacher1"}) + # assert response.status_code == 200 + # data = response.json + # for course in valid_course_entries: + # assert course.name in [c["name"] for c in data["data"]] + + # def test_course_delete(self, valid_course_entry, client): + # """Test all course endpoint related delete functionality""" + + # response = client.delete( + # "/courses/" + str(valid_course_entry.course_id), headers={"Authorization": "teacher2"} + # ) + # assert response.status_code == 200 + + # # Is not reachable using the API + # get_response = client.get(f"/courses/{valid_course_entry.course_id}", + # headers={"Authorization": "teacher2"}) + # assert get_response.status_code == 404 + + # def test_course_patch(self, valid_course_entry, client): + # """ + # Test the patching of a course + # """ + # response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ + # "name": "TestTest" + # }, headers={"Authorization": "teacher2"}) + # data = response.json + # assert response.status_code == 200 + # assert data["data"]["name"] == "TestTest" diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index 2df488fa..dc071b15 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -12,21 +12,21 @@ class TestCourseShareLinks: def test_get_share_links(self, client, valid_course_entry): """Test whether the share links are accessible""" response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", - headers={"Authorization":"teacher2"}) + headers={"Authorization":"teacher"}) assert response.status_code == 200 def test_post_share_links(self, client, valid_course_entry): """Test whether the share links are accessible to post to""" response = client.post( f"courses/{valid_course_entry.course_id}/join_codes", - json={"for_admins": True}, headers={"Authorization":"teacher2"}) + json={"for_admins": True}, headers={"Authorization":"teacher"}) assert response.status_code == 201 def test_delete_share_links(self, client, share_code_admin): """Test whether the share links are accessible to delete""" response = client.delete( f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", - headers={"Authorization":"teacher2"}) + headers={"Authorization":"teacher"}) assert response.status_code == 200 def test_get_share_links_404(self, client): @@ -45,5 +45,5 @@ def test_for_admins_required(self, client, valid_course_entry): """Test whether the for_admins field is required""" response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", json={}, - headers={"Authorization":"teacher2"}) + headers={"Authorization":"teacher"}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 2cda69b6..e5a6bbc4 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -17,7 +17,7 @@ def test_assignment_download(client, valid_project): assert response.status_code == 201 project_id = response.json["data"]["project_id"] response = client.get(f"/projects/{project_id}/assignments", - headers={"Authorization":"teacher2"}) + headers={"Authorization":"teacher"}) # file downloaded succesfully assert response.status_code == 200 @@ -54,14 +54,14 @@ def test_post_project(client, valid_project): response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data', headers={"Authorization":"teacher2"} + content_type='multipart/form-data', headers={"Authorization":"teacher"} ) assert response.status_code == 201 # check if the project with the id is present project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher"}) assert response.status_code == 200 @@ -69,11 +69,11 @@ def test_remove_project(client, valid_project_entry): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" project_id = valid_project_entry.project_id - response = client.delete(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) + response = client.delete(f"/projects/{project_id}", headers={"Authorization":"teacher"}) assert response.status_code == 200 # check if the project isn't present anymore and the delete indeed went through - response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher2"}) + response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher"}) assert response.status_code == 404 def test_patch_project(client, valid_project_entry): @@ -86,6 +86,6 @@ def test_patch_project(client, valid_project_entry): response = client.patch(f"/projects/{project_id}", json={ "title": new_title, "archived": new_archived - }, headers={"Authorization":"teacher2"}) + }, headers={"Authorization":"teacher"}) assert response.status_code == 200 diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 1354b4be..c8a8aba2 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -33,7 +33,7 @@ def test_get_submissions_wrong_project_type(self, client: FlaskClient): def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", - headers={"Authorization":"teacher2"}) + headers={"Authorization":"teacher"}) data = response.json assert response.status_code == 200 assert "message" in data From e43bf954cda45cd8f9e09bc3d164d037c8f246e1 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 1 Apr 2024 13:11:33 +0200 Subject: [PATCH 231/377] Feature/backend/documentation (#94) * cooken on the projects documentation * #92 - Fixing missing /submissions * #92 - Fixing apilinter * requested changes * openapi object * changed to yaml file * small fix * grammar * json->yaml * pyyaml installed in dev.txt * linter --------- Co-authored-by: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> --- backend/dev-requirements.txt | 1 + backend/project/static/OpenAPI_Object.json | 2272 -------------------- backend/project/static/OpenAPI_Object.yaml | 1333 ++++++++++++ backend/tests.yaml | 2 +- backend/tests/endpoints/index_test.py | 10 +- 5 files changed, 1342 insertions(+), 2276 deletions(-) delete mode 100644 backend/project/static/OpenAPI_Object.json create mode 100644 backend/project/static/OpenAPI_Object.yaml diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt index dd9b3470..fa950d3d 100644 --- a/backend/dev-requirements.txt +++ b/backend/dev-requirements.txt @@ -1,3 +1,4 @@ pytest pylint pylint-flask +pyyaml diff --git a/backend/project/static/OpenAPI_Object.json b/backend/project/static/OpenAPI_Object.json deleted file mode 100644 index ba0b5381..00000000 --- a/backend/project/static/OpenAPI_Object.json +++ /dev/null @@ -1,2272 +0,0 @@ -{ - "openapi": "3.0.1", - "info": { - "title": "Pigeonhole API", - "summary": "A project submission and grading API for University Ghent students and professors.", - "description": "The API built for the Pigeonhole application. It serves as an interface for student of University Ghent. They can submit solutions to projects created by their professors. Professors and their assistents can then review these submitions, grade them and define custom tests that automatically run on every submition. The API is built using the OpenAPI 3.1.0 specification.", - "version": "1.0.0", - "contact": { - "name": "Project discussion forum", - "url": "https://github.com/SELab-2/UGent-opgave/discussions", - "email": "Bart.Coppens@UGent.be" - }, - "x-authors": [ - { - "name": "Aron Buzogany", - "github": "https://github.com/AronBuzogany" - }, - { - "name": "Gerwoud Van den Eynden", - "github": "https://github.com/Gerwoud" - }, - { - "name": "Jarne Clauw", - "github": "https://github.com/JarneClauw" - }, - { - "name": "Siebe Vlietinck", - "github": "https://github.com/Vucis" - }, - { - "name": "Warre Provoost", - "github": "https://github.com/warreprovoost" - }, - { - "name": "Cedric Mekeirle", - "github": "https://github.com/JibrilExe" - }, - { - "name": "Matisse Sulzer", - "github": "https://github.com/Matisse-Sulzer" - } - ] - }, - "paths": { - "/projects": { - "get": { - "description": "Returns all projects from the database that the user has access to", - "responses": { - "200": { - "description": "A list of projects", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "project_id": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "post": { - "description": "Upload a new project", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "assignment_file": { - "type": "string", - "format": "binary" - }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "course_id": { "type": "integer" }, - "visible_for_students": { "type": "boolean" }, - "archived": { "type": "boolean" } - }, - "required": ["assignment_file", "title", "description", "course_id", "visible_for_students", "archived"] - } - } - } - }, - "responses": { - "201": { - "description": "Uploaded a new project succesfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Bad formatted request for uploading a project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Something went wrong inserting model into the database", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error":{ - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/projects/{id}": { - "get": { - "description": "Return a project with corresponding id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the project to retrieve", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "A project with corresponding id", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "project_id": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "assignment_file": { - "type": "string", - "format": "binary" - }, - "deadline": { - "type": "string" - }, - "course_id": { - "type": "integer" - }, - "visible_for_students": { - "type": "boolean" - }, - "archived": { - "type": "boolean" - }, - "test_path": { - "type": "string" - }, - "script_name": { - "type": "string" - }, - "regex_expressions": { - "type": "array" - } - } - } - } - } - }, - "404": { - "description": "An id that doesn't correspond to an existing project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object" - }, - "message": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Something in the database went wrong fetching the project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - } - } - }, - "patch": { - "description": "Patch certain fields of a project", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the project to retrieve", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "assignment_file": { - "type": "string", - "format": "binary" - }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "course_id": { "type": "integer" }, - "visible_for_students": { "type": "boolean" }, - "archived": { "type": "boolean" } - } - } - } - } - }, - "responses": { - "200": { - "description": "Patched a project succesfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object" - }, - "message": { - "type": "string" - }, - "url": { "type": "string" } - } - } - } - } - }, - "404": { - "description": "Tried to patch a project that is not present", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Something went wrong in the database trying to patch the project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Delete a project with given id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the project to retrieve", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Removed a project succesfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" }, - "url": { "type": "string" } - } - } - } - } - }, - "404": { - "description": "Tried to remove a project that is not present", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" }, - "url": { "type": "string" } - } - } - } - } - }, - "500": { - "description": "Something went wrong in the database trying to remove the project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { "type": "string" }, - "url": { "type": "string" } - } - } - } - } - } - } - } - }, - "/courses": { - "get": { - "description": "Get a list of all courses.", - "responses": { - "200": { - "description": "Successfully retrieved all courses with given parameters", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "course_id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "ufora_id": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - }, - "parameters": [ - { - "name": "name", - "in": "query", - "description": "Name of the course", - "schema": { - "type": "string" - } - }, - { - "name": "ufora_id", - "in": "query", - "description": "Ufora ID of the course", - "schema": { - "type": "string" - } - }, - { - "name": "teacher", - "in": "query", - "description": "Teacher of the course", - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "description": "Create a new course.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the course" - }, - "teacher": { - "type": "string", - "description": "Teacher of the course" - } - }, - "required": [ - "name", - "teacher" - ] - } - } - } - }, - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Course with name: {name} and course_id: {course_id} was successfully created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "course_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "ufora_id": { - "type": "string" - } - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to create a course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "The user trying to create a course was not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/courses/{course_id}": { - "get": { - "description": "Get a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Course found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "ufora_id": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "admins": { - "type": "array", - "items": { - "type": "string" - } - }, - "students": { - "type": "array", - "items": { - "type": "string" - } - }, - "projects": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Delete a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Course deleted.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "Successfully deleted course with course_id: {course_id}" - ] - }, - "url": { - "type": "string", - "examples": [ - "{API_URL}/courses" - ] - } - } - } - } - } - }, - "403": { - "description": "The user trying to delete the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "patch": { - "description": "Update the course with given ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the course" - }, - "teacher": { - "type": "string", - "description": "Teacher of the course" - }, - "ufora_id": { - "type": "string", - "description": "Ufora ID of the course" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Course updated.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "url": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "course_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "ufora_id": { - "type": "string" - } - } - } - } - } - } - } - }, - "403": { - "description": "The user trying to update the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/courses/{course_id}/students": { - "get": { - "description": "Get a list of all students in a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved all students of course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "post": { - "description": "Assign students to a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "students", - "in": "body", - "description": "list of uids of the students to be assigned to the course", - "required": true, - "schema": { - "type": "array" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Students assigned to course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "User were succesfully added to the course" - ] - }, - "url": { - "type": "string", - "examples": [ - "http://api.example.com/courses/123/students" - ] - }, - "data": { - "type": "object", - "properties": { - "students": { - "type": "array", - "items": { - "type": "string", - "examples": [ - "http://api.example.com/users/123" - ] - } - } - } - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to assign students to the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Remove students from a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "students", - "in": "body", - "description": "list of uids of the students to be removed from the course", - "required": true, - "schema": { - "type": "array" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Students removed from course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": "User were succesfully removed from the course" - }, - "url": { - "type": "string", - "examples": [ - "API_URL + /courses/ + str(course_id) + /students" - ] - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to remove students from the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/courses/{course_id}/admins": { - "get": { - "description": "Get a list of all admins in a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved all admins of course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "post": { - "description": "Assign admins to a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "admin_uid", - "in": "body", - "description": "uid of the admin to be assigned to the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "User were successfully added to the course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "User were successfully added to the course." - ] - }, - "url": { - "type": "string", - "examples": [ - "http://api.example.com/courses/123/students" - ] - }, - "data": { - "type": "object", - "properties": { - "students": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - "http://api.example.com/users/1", - "http://api.example.com/users/2" - ] - } - } - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to assign admins to the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Remove an admin from a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "admin_uid", - "in": "body", - "description": "uid of the admin to be removed from the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "User was successfully removed from the course admins.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "User was successfully removed from the course admins." - ] - }, - "url": { - "type": "string", - "examples": [ - "API_URL + /courses/ + str(course_id) + /students" - ] - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to remove the admin from the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/users": { - "get": { - "summary": "Get all users", - "responses": { - "200": { - "description": "A list of users", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "uid", - "is_teacher", - "is_admin" - ] - } - } - } - } - } - } - }, - "post": { - "summary": "Create a new user", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "uid", - "is_teacher", - "is_admin" - ] - } - } - } - }, - "responses": { - "201": { - "description": "User created successfully" - }, - "400": { - "description": "Invalid request data" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while creating the user" - } - } - }, - "/users/{user_id}": { - "get": { - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "A user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "uid", - "is_teacher", - "is_admin" - ] - } - } - } - }, - "404": { - "description": "User not found" - } - } - }, - "patch": { - "summary": "Update a user's information", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "is_teacher", - "is_admin" - ] - } - } - } - }, - "responses": { - "200": { - "description": "User updated successfully" - }, - "404": { - "description": "User not found" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while patching the user" - } - } - }, - "delete": { - "summary": "Delete a user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "404": { - "description": "User not found" - }, - "500": { - "description": "An error occurred while deleting the user" - } - } - } - } - } - }, - "/submissions": { - "get": { - "summary": "Gets the submissions", - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "User ID", - "schema": { - "type": "string" - } - }, - { - "name": "project_id", - "in": "query", - "description": "Project ID", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved a list of submission URLs", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submissions": "array", - "items": { - "type": "string", - "format": "uri" - } - } - } - } - } - } - } - }, - "400": { - "description": "An invalid user or project is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "post": { - "summary": "Posts a new submission to a project", - "requestBody": { - "description": "Form data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string", - "required": true - }, - "project_id": { - "type": "integer", - "required": true - }, - "files": { - "type": "array", - "items": { - "type": "file" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Successfully posts the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } - } - } - } - } - } - } - }, - "400": { - "description": "An invalid user, project or list of files is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/submissions/{submission_id}": { - "get": { - "summary": "Gets the submission", - "responses": { - "200": { - "description": "Successfully retrieved the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submission": { - "type": "object", - "properties": { - "submission_id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer", - "nullable": true - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } - } - } - } - } - } - } - } - } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "patch": { - "summary": "Patches the submission", - "requestBody": { - "description": "The submission data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successfully patches the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } - } - } - } - } - } - } - }, - "400": { - "description": "An invalid submission grading is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "summary": "Deletes the submission", - "responses": { - "200": { - "description": "Successfully deletes the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "parameters": [ - { - "name": "submission_id", - "in": "path", - "description": "Submission ID", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } -} \ No newline at end of file diff --git a/backend/project/static/OpenAPI_Object.yaml b/backend/project/static/OpenAPI_Object.yaml new file mode 100644 index 00000000..6b049c98 --- /dev/null +++ b/backend/project/static/OpenAPI_Object.yaml @@ -0,0 +1,1333 @@ +--- +openapi: 3.0.1 +info: + title: Pigeonhole API + summary: A project submission and grading API for University Ghent students and + professors. + description: The API built for the Pigeonhole application. It serves as an interface + for student of University Ghent. They can submit solutions to projects created + by their professors. Professors and their assistents can then review these submitions, + grade them and define custom tests that automatically run on every submition. + The API is built using the OpenAPI 3.1.0 specification. + version: 1.0.0 + contact: + name: Project discussion forum + url: https://github.com/SELab-2/UGent-opgave/discussions + email: Bart.Coppens@UGent.be + x-authors: + - name: Aron Buzogany + github: https://github.com/AronBuzogany + - name: Gerwoud Van den Eynden + github: https://github.com/Gerwoud + - name: Jarne Clauw + github: https://github.com/JarneClauw + - name: Siebe Vlietinck + github: https://github.com/Vucis + - name: Warre Provoost + github: https://github.com/warreprovoost + - name: Cedric Mekeirle + github: https://github.com/JibrilExe + - name: Matisse Sulzer + github: https://github.com/Matisse-Sulzer +paths: + "/projects": + get: + description: Returns all projects from the database that the user has access + to + responses: + '200': + description: A list of projects + content: + application/json: + schema: + type: array + items: + type: object + properties: + project_id: + type: integer + description: + type: string + title: + type: string + example: + - project_id: 1 + description: Project 1 description + title: Project 1 + - project_id: 2 + description: Project 2 description + title: Project 2 + '500': + description: Internal Server Error + post: + description: Upload a new project + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + assignment_file: + type: string + format: binary + title: + type: string + description: + type: string + course_id: + type: integer + visible_for_students: + type: boolean + archived: + type: boolean + required: + - assignment_file + - title + - description + - course_id + - visible_for_students + - archived + responses: + '201': + description: Project created successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + url: + type: string + '400': + description: Bad formatted request for uploading a project + content: + application/json: + schema: + type: object + properties: + message: + type: string + url: + type: string + '500': + $ref: '#/components/responses/InternalError' + "/projects/{project_id}": + get: + description: Return a project with corresponding id + parameters: + - name: project_id + in: path + description: ID of the project to retrieve + required: true + schema: + type: integer + responses: + '200': + description: Project details retrieved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '404': + description: An id that doesn't correspond to an existing project + content: + application/json: + schema: + type: object + properties: + data: + type: object + message: + type: string + url: + type: string + '500': + $ref: '#/components/responses/InternalError' + patch: + description: Patch certain fields of a project + parameters: + - name: id + in: path + description: ID of the project to retrieve + required: true + schema: + type: integer + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + assignment_file: + type: string + format: binary + title: + type: string + description: + type: string + course_id: + type: integer + visible_for_students: + type: boolean + archived: + type: boolean + responses: + '200': + description: Patched a project succesfully + content: + application/json: + schema: + type: object + properties: + data: + type: object + message: + type: string + url: + type: string + '404': + description: Tried to patch a project that is not present + content: + application/json: + schema: + type: object + properties: + message: + type: string + url: + type: string + '500': + $ref: '#/components/responses/InternalError' + delete: + description: Delete a project with corresponding project id and all of its submissions in cascade + parameters: + - name: id + in: path + description: ID of the project to retrieve + required: true + schema: + type: integer + responses: + '200': + description: Project deleted successfully + content: + application/json: + schema: + type: object + properties: + message: + type: string + url: + type: string + '404': + description: Tried to remove a project that is not present + content: + application/json: + schema: + type: object + properties: + error: + type: string + url: + type: string + "/projects/{id}/assignments": + get: + description: Get the assignment files of project with given id + parameters: + - name: project_id + in: path + description: ID of the project to retrieve + required: true + schema: + type: integer + responses: + '200': + description: Successfully downloaded the assignment files of the project + content: + multipart/form-data: + schema: + type: string + format: binary + '404': + description: The project of which you wanted to get the assignment files of doesn't exist + '500': + $ref: '#/components/responses/InternalError' + "/courses": + get: + description: Get a list of all courses. + responses: + '200': + description: Successfully retrieved all courses with given parameters + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: array + items: + type: object + properties: + course_id: + type: integer + name: + type: string + ufora_id: + type: string + teacher: + type: string + url: + type: string + url: + type: string + '500': + $ref: '#/components/responses/InternalError' + parameters: + - name: name + in: query + description: Name of the course + schema: + type: string + - name: ufora_id + in: query + description: Ufora ID of the course + schema: + type: string + - name: teacher + in: query + description: Teacher of the course + schema: + type: string + post: + description: Create a new course. + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name of the course + teacher: + type: string + description: Teacher of the course + required: + - name + - teacher + parameters: + - name: uid + in: query + description: uid of the user sending the request + schema: + type: string + responses: + '201': + description: 'Course with name: {name} and course_id: {course_id} was successfully + created' + content: + application/json: + schema: + type: object + properties: + message: + type: string + data: + type: object + properties: + course_id: + type: string + name: + type: string + teacher: + type: string + ufora_id: + type: string + url: + type: string + '400': + description: There was no uid in the request query. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '403': + description: The user trying to create a course was unauthorized. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: The user trying to create a course was not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + "/courses/{course_id}": + get: + description: Get a course by its ID. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + responses: + '200': + $ref: '#/components/schemas/Course' + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + delete: + description: Delete a course by its ID. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + responses: + '204': + description: Course deleted. + content: + application/json: + schema: + type: object + properties: + message: + type: string + examples: + - 'Successfully deleted course with course_id: {course_id}' + url: + type: string + examples: + - "{API_URL}/courses" + '403': + description: The user trying to delete the course was unauthorized. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + patch: + description: Update the course with given ID. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: Name of the course + teacher: + type: string + description: Teacher of the course + ufora_id: + type: string + description: Ufora ID of the course + responses: + '200': + description: Course updated. + content: + application/json: + schema: + type: object + properties: + message: + type: string + url: + type: string + data: + type: object + properties: + course_id: + type: string + name: + type: string + teacher: + type: string + ufora_id: + type: string + '403': + description: The user trying to update the course was unauthorized. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + "/courses/{course_id}/students": + get: + description: Get a list of all students in a course. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + responses: + '200': + description: Successfully retrieved all students of course. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: string + url: + type: string + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + post: + description: Assign students to a course. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + - name: students + in: body + description: List of uids of the students to be assigned to the course + required: true + schema: + type: array + - name: uid + in: query + description: Uid of the user sending the request + schema: + type: string + responses: + '201': + description: Students assigned to course. + content: + application/json: + schema: + type: object + properties: + message: + type: string + examples: + - User was succesfully added to the course + url: + type: string + examples: + - http://api.example.com/courses/123/students + data: + type: object + properties: + students: + type: array + items: + type: string + examples: + - http://api.example.com/users/123 + '400': + description: There was no uid in the request query. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '403': + description: The user trying to assign students to the course was unauthorized. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + delete: + description: Remove students from a course. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + - name: students + in: body + description: List of uids of the students to be removed from the course + required: true + schema: + type: array + - name: uid + in: query + description: Uid of the user sending the request + schema: + type: string + responses: + '204': + description: Students removed from course. + content: + application/json: + schema: + type: object + properties: + message: + type: string + examples: User was succesfully removed from the course + url: + type: string + examples: + - API_URL + /courses/ + str(course_id) + /students + '400': + description: There was no uid in the request query. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '403': + description: The user trying to remove students from the course was unauthorized. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + "/courses/{course_id}/admins": + get: + description: Get a list of all admins in a course. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + responses: + '200': + $ref: '#/components/schemas/Course' + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + post: + description: Assign admins to a course. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + - name: admin_uid + in: body + description: Uid of the admin to be assigned to the course + required: true + schema: + type: string + - name: uid + in: query + description: Uid of the user sending the request + schema: + type: string + responses: + '201': + description: User were successfully added to the course. + content: + application/json: + schema: + type: object + properties: + message: + type: string + examples: + - User was successfully added to the course. + url: + type: string + examples: + - http://api.example.com/courses/123/students + data: + type: object + properties: + students: + type: array + items: + type: string + examples: + - http://api.example.com/users/1 + - http://api.example.com/users/2 + '400': + description: There was no uid in the request query. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '403': + description: The user trying to assign admins to the course was unauthorized. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + delete: + description: Remove an admin from a course. + parameters: + - name: course_id + in: path + description: ID of the course + required: true + schema: + type: string + - name: admin_uid + in: body + description: Uid of the admin to be removed from the course + required: true + schema: + type: string + - name: uid + in: query + description: Uid of the user sending the request + schema: + type: string + responses: + '204': + description: User was successfully removed from the course admins. + content: + application/json: + schema: + type: object + properties: + message: + type: string + examples: + - User was successfully removed from the course admins. + url: + type: string + examples: + - API_URL + /courses/ + str(course_id) + /students + '400': + description: There was no uid in the request query. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '403': + description: The user trying to remove the admin from the course was unauthorized. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '404': + description: Course not found. + content: + application/json: + schema: + type: object + properties: + error: + type: string + '500': + $ref: '#/components/responses/InternalError' + "/users": + get: + summary: Get all users + responses: + '200': + description: A list of users + content: + application/json: + schema: + type: array + items: + type: object + properties: + uid: + type: string + is_teacher: + type: boolean + is_admin: + type: boolean + required: + - uid + - is_teacher + - is_admin + post: + summary: Create a new user + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + uid: + type: string + is_teacher: + type: boolean + is_admin: + type: boolean + required: + - uid + - is_teacher + - is_admin + responses: + '201': + description: User created successfully + '400': + description: Invalid request data + '415': + description: Unsupported Media Type. Expected JSON. + '500': + $ref: '#/components/responses/InternalError' + "/users/{user_id}": + get: + summary: Get a user by ID + parameters: + - name: user_id + in: path + required: true + schema: + type: string + responses: + '200': + description: A user + content: + application/json: + $ref: '#/components/schemas/User' + '404': + description: User not found + patch: + summary: Update a user's information + parameters: + - name: user_id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + is_teacher: + type: boolean + is_admin: + type: boolean + required: + - is_teacher + - is_admin + responses: + '200': + description: User updated successfully + '404': + description: User not found + '415': + description: Unsupported Media Type. Expected JSON. + '500': + $ref: '#/components/responses/InternalError' + delete: + summary: Delete a user + parameters: + - name: user_id + in: path + required: true + schema: + type: string + responses: + '200': + description: User deleted successfully + '404': + description: User not found + '500': + $ref: '#/components/responses/InternalError' + "/submissions": + get: + summary: Gets the submissions + parameters: + - name: uid + in: query + description: User ID + schema: + type: string + - name: project_id + in: query + description: Project ID + schema: + type: integer + responses: + '200': + description: Successfully retrieved a list of submission URLs + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + data: + type: object + properties: + submissions: array + items: + type: string + format: uri + '400': + description: An invalid user or project is given + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + '500': + $ref: '#/components/responses/InternalError' + post: + summary: Posts a new submission to a project + requestBody: + description: Form data + content: + application/json: + schema: + type: object + properties: + uid: + type: string + required: true + project_id: + type: integer + required: true + files: + type: array + items: + type: file + responses: + '201': + description: Successfully posts the submission and retrieves its data + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + data: + type: object + properties: + id: + type: integer + user: + type: string + format: uri + project: + type: string + format: uri + grading: + type: integer + time: + type: string + format: date-time + path: + type: string + status: + type: boolean + '400': + description: An invalid user, project or list of files is given + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + '500': + $ref: '#/components/responses/InternalError' + "/submissions/{submission_id}": + get: + summary: Gets the submission + responses: + '200': + description: Successfully retrieved the submission + content: + application/json: + schema: + $ref: '#/components/schemas/Submission' + '404': + description: An invalid submission id is given + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + '500': + $ref: '#/components/responses/InternalError' + patch: + summary: Patches the submission + requestBody: + description: The submission data + content: + application/json: + schema: + type: object + properties: + grading: + type: integer + minimum: 0 + maximum: 20 + responses: + '200': + description: Successfully patches the submission and retrieves its data + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + data: + type: object + properties: + id: + type: integer + user: + type: string + format: uri + project: + type: string + format: uri + grading: + type: integer + time: + type: string + format: date-time + path: + type: string + status: + type: boolean + '400': + description: An invalid submission grading is given + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + '404': + description: An invalid submission id is given + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + '500': + $ref: '#/components/responses/InternalError' + delete: + summary: Deletes the submission + responses: + '200': + description: Successfully deletes the submission + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + '404': + description: An invalid submission id is given + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + message: + type: string + '500': + $ref: '#/components/responses/InternalError' + parameters: + - name: submission_id + in: path + description: Submission ID + required: true + schema: + type: integer +components: + responses: + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + Error: + type: object + properties: + code: + type: string + message: + type: string + required: + - code + - message + Project: + type: object + properties: + project_id: + type: integer + title: + type: string + description: + type: string + assignment_file: + type: string + format: binary + deadline: + type: string + format: date-time + course_id: + type: integer + visible_for_students: + type: boolean + archived: + type: boolean + test_path: + type: string + script_name: + type: string + regex_expressions: + type: array + items: + type: string + Course: + type: object + properties: + message: + type: string + data: + type: object + properties: + ufora_id: + type: string + teacher: + type: string + admins: + type: array + items: + type: string + students: + type: array + items: + type: string + projects: + type: array + items: + type: string + url: + type: string + User: + type: object + properties: + uid: + type: string + is_teacher: + type: boolean + is_admin: + type: boolean + required: + - uid + - is_teacher + - is_admin + Submission: + type: object + properties: + url: + type: string + format: uri + message: + type: string + data: + type: object + properties: + submission: + type: object + properties: + submission_id: + type: integer + user: + type: string + format: uri + project: + type: string + format: uri + grading: + type: integer + nullable: true + time: + type: string + format: date-time + path: + type: string + status: + type: boolean \ No newline at end of file diff --git a/backend/tests.yaml b/backend/tests.yaml index dad5289a..7270889b 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -43,7 +43,7 @@ services: API_HOST: http://api_is_here AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose UPLOAD_URL: /data/assignments - DOCS_JSON_PATH: static/OpenAPI_Object.json + DOCS_JSON_PATH: static/OpenAPI_Object.yaml DOCS_URL: /docs volumes: - .:/app diff --git a/backend/tests/endpoints/index_test.py b/backend/tests/endpoints/index_test.py index 8f3a5d4e..9f1cceee 100644 --- a/backend/tests/endpoints/index_test.py +++ b/backend/tests/endpoints/index_test.py @@ -1,5 +1,7 @@ """Test the base routes of the application""" +import yaml + def test_home(client): """Test whether the index page is accesible""" @@ -10,6 +12,8 @@ def test_home(client): def test_openapi_spec(client): """Test whether the required fields of the openapi spec are present""" response = client.get("/") - response_json = response.json - assert response_json["openapi"] is not None - assert response_json["info"] is not None + response_text = response.text + response_yaml = yaml.safe_load(response_text) + + assert "openapi" in response_yaml + assert "info" in response_yaml From 0aea3a349081cc4ac5eebbd6c1e3ccd60062897b Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Mon, 1 Apr 2024 13:12:38 +0200 Subject: [PATCH 232/377] Base test class to handle auth (#153) * Adding auth tokens * Setup for auth tests * Fixing tests * Cleanup * Spelling * Remove unnecessary commit --- backend/test_auth_server/__main__.py | 36 ++++++++++++++++++ backend/tests/conftest.py | 23 ++++++++++-- backend/tests/endpoints/conftest.py | 18 +++++++++ backend/tests/endpoints/endpoint.py | 56 ++++++++++++++++++++++++++++ backend/tests/models/user_test.py | 6 +-- 5 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 backend/tests/endpoints/endpoint.py diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index bf5fd576..1dc6302f 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -8,6 +8,7 @@ index_bp = Blueprint("index", __name__) index_endpoint = Api(index_bp) +# Take the key the same as the id, uid can then be used in backend token_dict = { "teacher1":{ "id":"Gunnar", @@ -44,6 +45,41 @@ "admin1":{ "id":"admin_person", "jobTitle":"admin" + }, + # Lowest authorized user to test login requirement + "login": { + "id": "login", + "jobTitle": None + }, + # Student authorization access, associated with valid_... + "student": { + "id": "student", + "jobTitle": None + }, + # Student authorization access, other + "student_other": { + "id": "student_other", + "jobTitle": None + }, + # Teacher authorization access, associated with valid_... + "teacher": { + "id": "teacher", + "jobTitle": "teacher" + }, + # Teacher authorization access, other + "teacher_other": { + "id": "teacher_other", + "jobTitle": "teacher" + }, + # Admin authorization access, associated with valid_... + "admin": { + "id": "admin", + "jobTitle": "admin" + }, + # Admin authorization access, other + "admin_other": { + "id": "admin_other", + "jobTitle": "admin" } } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index fe9d3961..a7cc092b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,7 +2,7 @@ from datetime import datetime from zoneinfo import ZoneInfo -import pytest +from pytest import fixture from project.sessionmaker import engine, Session from project.db_in import db from project.models.course import Course @@ -11,7 +11,7 @@ from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission, SubmissionStatus -@pytest.fixture +@fixture def db_session(): """Create a new database session for a test. After the test, all changes are rolled back and the session is closed.""" @@ -123,7 +123,22 @@ def submissions(session): ) ] -@pytest.fixture +### AUTHENTICATION & AUTHORIZATION ### +def auth_tokens(): + """Add the authenticated users to the database""" + + return [ + User(uid="login", role=Role.STUDENT), + User(uid="student", role=Role.STUDENT), + User(uid="student_other", role=Role.STUDENT), + User(uid="teacher", role=Role.TEACHER), + User(uid="teacher_other", role=Role.TEACHER), + User(uid="admin", role=Role.ADMIN), + User(uid="admin_other", role=Role.ADMIN) + ] + +### SESSION ### +@fixture def session(): """Create a new database session for a test. After the test, all changes are rolled back and the session is closed.""" @@ -132,6 +147,8 @@ def session(): session = Session() try: + session.add_all(auth_tokens()) + # Populate the database session.add_all(users()) session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index b76e8369..fd46dd8a 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -5,6 +5,8 @@ from datetime import datetime from zoneinfo import ZoneInfo import pytest +from pytest import fixture, FixtureRequest +from flask.testing import FlaskClient from sqlalchemy import create_engine from sqlalchemy.exc import SQLAlchemyError from project.models.user import User,Role @@ -15,7 +17,23 @@ from project.models.submission import Submission, SubmissionStatus from project.models.project import Project +### AUTHENTICATION & AUTHORIZATION ### +@fixture +def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry): + """Add concrete test data""" + # endpoint, parameters, method, token, status + endpoint, parameters, method, *other = request.param + d = { + "course_id": valid_course_entry.course_id + } + + for index, parameter in enumerate(parameters): + endpoint = endpoint.replace(f"@{index}", str(d[parameter])) + + return endpoint, getattr(client, method), *other + +### OTHER ### @pytest.fixture def valid_submission(valid_user_entry, valid_project_entry): """ diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py new file mode 100644 index 00000000..1c468164 --- /dev/null +++ b/backend/tests/endpoints/endpoint.py @@ -0,0 +1,56 @@ +"""Base class for endpoint tests""" + +from typing import List, Tuple +from pytest import param + +def authentication_tests(tests: List[Tuple[str, List[str], List[str]]]) -> List[any]: + """Transform the format to single authentication tests""" + + single_tests = [] + for test in tests: + endpoint, parameters, methods = test + for method in methods: + single_tests.append(param( + (endpoint, parameters, method), + id = f"{endpoint} {method}" + )) + return single_tests + +def authorization_tests(tests: List[Tuple[str, List[str], str, List[str], List[str]]]) -> List[any]: + """Transform the format to single authorization tests""" + + single_tests = [] + for test in tests: + endpoint, parameters, method, allowed_tokens, disallowed_tokens = test + for token in (allowed_tokens + disallowed_tokens): + allowed = token in allowed_tokens + single_tests.append(param( + (endpoint, parameters, method, token, allowed), + id = f"{endpoint} {method} {token} {'allowed' if allowed else 'disallowed'}" + )) + return single_tests + +class TestEndpoint: + """Base class for endpoint tests""" + + def authentication(self, authentication_parameter: Tuple[str, any]): + """Test if the authentication for the given enpoint works""" + + endpoint, method = authentication_parameter + + response = method(endpoint) + assert response.status_code == 401 + + response = method(endpoint, headers = {"Authorization": "0123456789"}) + assert response.status_code == 401 + + response = method(endpoint, headers = {"Authorization": "login"}) + assert response.status_code != 401 + + def authorization(self, auth_parameter: Tuple[str, any, str, bool]): + """Test if the authorization for the given endpoint works""" + + endpoint, method, token, allowed = auth_parameter + + response = method(endpoint, headers = {"Authorization": token}) + assert allowed == (response.status_code != 403) diff --git a/backend/tests/models/user_test.py b/backend/tests/models/user_test.py index 05520b8c..d607ebad 100644 --- a/backend/tests/models/user_test.py +++ b/backend/tests/models/user_test.py @@ -14,11 +14,11 @@ def test_create_user(self, session: Session): session.add(user) session.commit() assert session.get(User, "user01") is not None - assert session.query(User).count() == 5 + assert session.query(User).count() == 12 def test_query_user(self, session: Session): """Test if a user can be queried""" - assert session.query(User).count() == 4 + assert session.query(User).count() == 11 teacher = session.query(User).filter_by(uid="brinkmann").first() assert teacher is not None assert teacher.role == Role.ADMIN @@ -36,7 +36,7 @@ def test_delete_user(self, session: Session): session.delete(user) session.commit() assert session.get(User, user.uid) is None - assert session.query(User).count() == 3 + assert session.query(User).count() == 10 @mark.parametrize("property_name", ["uid"]) def test_property_not_nullable(self, session: Session, property_name: str): From 888689383c0028005ec7a31ef3cac5a8904edb79 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 2 Apr 2024 13:22:55 +0200 Subject: [PATCH 233/377] navbar (#80) * navbar * changes * oops * close bar on redirect * linter conflicts for component function * scalability matters * useEffect to check login state and adjust link item list * a little more readable --- frontend/.eslintrc.cjs | 6 +- frontend/index.html | 2 +- frontend/package-lock.json | 3 + frontend/package.json | 3 + frontend/src/App.tsx | 8 +- frontend/src/components/Header/Header.tsx | 110 ++++++++++++++++++---- 6 files changed, 106 insertions(+), 26 deletions(-) diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index c685b20b..0229c600 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -26,7 +26,7 @@ module.exports = { "jsdoc/check-access": 1, "tsdoc/syntax": "warn", "jsdoc/check-alignment": 1, - "jsdoc/check-param-names": 1, + "jsdoc/check-param-names": 0, "jsdoc/check-property-names": 1, "jsdoc/check-tag-names": 1, "jsdoc/check-types": 1, @@ -37,10 +37,10 @@ module.exports = { "jsdoc/no-multi-asterisks": 1, "jsdoc/no-undefined-types": 1, "jsdoc/require-jsdoc": 1, - "jsdoc/require-param": 1, + "jsdoc/require-param": 0, "jsdoc/require-param-description": 1, "jsdoc/require-param-name": 1, - "jsdoc/require-param-type": 1, + "jsdoc/require-param-type": 0, "jsdoc/require-property": 1, "jsdoc/require-property-description": 1, "jsdoc/require-property-name": 1, diff --git a/frontend/index.html b/frontend/index.html index d42d4487..43fdb5e7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8467e285..c9f36c33 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,8 +13,11 @@ "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^14.1.0", "react-router-dom": "^6.22.1", "styled-components": "^6.1.8" }, diff --git a/frontend/package.json b/frontend/package.json index dfb8c6fa..c53f82b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,11 @@ "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", + "i18next-browser-languagedetector": "^7.2.0", + "i18next-http-backend": "^2.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-i18next": "^14.1.0", "react-router-dom": "^6.22.1", "styled-components": "^6.1.8" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 22932a0e..307c2751 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,6 @@ -import { BrowserRouter, Route, Routes } from "react-router-dom"; -import Home from "./pages/home/Home"; +import { BrowserRouter,Route,Routes } from "react-router-dom"; import { Header } from "./components/Header/Header"; - +import Home from "./pages/home/Home"; /** * This component is the main application component that will be rendered by the ReactDOM. * @returns - The main application component @@ -16,5 +15,4 @@ function App(): JSX.Element { ); } - -export default App; +export default App; \ No newline at end of file diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 8595e6d4..53846db5 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,33 +1,109 @@ -import { - AppBar, - Box, - Button, - IconButton, - Toolbar, - Typography, -} from "@mui/material"; -import MenuIcon from "@mui/icons-material/Menu"; -import { useTranslation } from "react-i18next"; +import { AppBar, Box, Button, Drawer, Grid, IconButton, List, ListItemButton, ListItemText, Toolbar, Typography } from "@mui/material"; +import { Menu } from "@mui/icons-material"; +import { useTranslation } from 'react-i18next'; +import { Link, useLocation } from 'react-router-dom'; +import { useEffect, useState } from "react"; /** - * The header component for the application that will be rendered at the top of the page. - * @returns - The header component + * Renders the header component. + * @returns JSX.Element representing the header. */ export function Header(): JSX.Element { const { t } = useTranslation(); + const location = useLocation(); + const [open, setOpen] = useState(false); + const [listItems, setListItems] = useState([ + { link: "/", text: t("homepage") } + ]); + + useEffect(() => { + const baseItems = [{ link: "/", text: t("homepage") }]; + const additionalItems = [ + { link: "/projects", text: t("myProjects") }, + { link: "/courses", text: t("myCourses") } + ]; + if (isLoggedIn()) { + setListItems([...baseItems, ...additionalItems]); + } + else { + setListItems(baseItems); + } + }, [t]); + + const title = getTitle(location.pathname, t); + return ( - - - + + setOpen(!open)} sx={{ color: "white", marginLeft: 0 }}> + - {t('home')} + {title} - + + + setOpen(false)} listItems={listItems}/> ); } +/** + * @returns Whether a user is logged in or not. + */ +function isLoggedIn() { + return true; +} + +/** + * Get the title based on the given pathname. + * @param pathname - The current pathname. + * @param t - The translation function. + * @returns The title. + */ +function getTitle(pathname: string, t: (key: string) => string): string { + switch(pathname) { + case '/': return t('home'); + case '/login': return t('login'); + case '/courses': return t('myCourses'); + case '/projects': return t('myProjects'); + default: return t('home'); + } +} + +/** + * Renders the drawer menu component. + * @param open - Whether the drawer menu is open or not. + * @param onClose - Function to handle the close event of the drawer menu. + * @param listItems - Array of objects representing the list items in the drawer menu. + * @returns The Side Bar + */ +function DrawerMenu({ open, onClose, listItems }: { open: boolean, onClose: () => void, listItems: { link: string, text: string }[] }) { + + return ( + + + + + + + + + {listItems.map((listItem, index) => ( + + + + ))} + + + + ); +} From 53193954208768003dcfa495e59e800cb534a913 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Wed, 3 Apr 2024 13:05:10 +0200 Subject: [PATCH 234/377] No longer authenticating teachers/students through url params (#165) * Fix #105 * adjusted tests to not use url query as authentication --- .../courses/course_admin_relation.py | 6 ++--- .../courses/course_student_relation.py | 6 ++--- .../endpoints/courses/courses_utils.py | 23 +++---------------- .../tests/endpoints/course/courses_test.py | 8 +++---- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index 43c1cf1e..16d26af6 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -56,10 +56,9 @@ def post(self, course_id): Api endpoint for adding new admins to a course, can only be done by the teacher """ abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") - teacher = request.args.get("uid") data = request.get_json() assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + abort_if_not_teacher_or_none_assistant(course_id, assistant) query = User.query.filter_by(uid=assistant) new_admin = execute_query_abort_if_db_error(query, abort_url) @@ -82,10 +81,9 @@ def delete(self, course_id): Api endpoint for removing admins of a course, can only be done by the teacher """ abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") - teacher = request.args.get("uid") data = request.get_json() assistant = data.get("admin_uid") - abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant) + abort_if_not_teacher_or_none_assistant(course_id, assistant) query = CourseAdmin.query.filter_by(uid=assistant, course_id=course_id) admin_relation = execute_query_abort_if_db_error(query, abort_url) diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 369fc4c2..421666bd 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -64,11 +64,10 @@ def post(self, course_id): /courses/course_id/students with a list of uid in the request body under key "students" """ abort_url = f"{API_URL}/courses/{course_id}/students" - uid = request.args.get("uid") data = request.get_json() student_uids = data.get("students") abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids + course_id, student_uids ) for uid in student_uids: @@ -96,11 +95,10 @@ def delete(self, course_id): a field "students" = [list of uids to unassign] """ abort_url = f"{API_URL}/courses/{str(course_id)}/students" - uid = request.args.get("uid") data = request.get_json() student_uids = data.get("students") abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids + course_id, student_uids ) for uid in student_uids: diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index 4c01ee73..57e9480c 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -90,26 +90,19 @@ def commit_abort_if_error(url): abort(500, description=response) -def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): +def abort_if_not_teacher_or_none_assistant(course_id, assistant): """ Check if the current user is authorized to appoint new admins to a course. Args: course_id (int): The ID of the course. + assistant (int): The UID of the person to be made an admin. Raises: HTTPException: If the current user is not authorized or if the UID of the person to be made an admin is missing in the request body. """ url = f"{API_URL}/courses/{str(course_id)}/admins" - abort_if_uid_is_none(teacher, url) - - course = get_course_abort_if_not_found(course_id) - - if teacher != course.teacher: - response = json_message("Only the teacher of a course can appoint new admins") - response["url"] = url - abort(403, description=response) if not assistant: response = json_message( @@ -120,7 +113,7 @@ def abort_if_not_teacher_or_none_assistant(course_id, teacher, assistant): def abort_if_none_uid_student_uids_or_non_existant_course_id( - course_id, uid, student_uids + course_id, student_uids ): """ Check the request to assign new students to a course. @@ -134,16 +127,6 @@ def abort_if_none_uid_student_uids_or_non_existant_course_id( """ url = f"{API_URL}/courses/{str(course_id)}/students" get_course_abort_if_not_found(course_id) - abort_if_no_user_found_for_uid(uid, url) - query = CourseAdmin.query.filter_by(uid=uid, course_id=course_id) - admin_relation = execute_query_abort_if_db_error(query, url) - if not admin_relation: - message = "Not authorized to assign new students to course with id " + str( - course_id - ) - response = json_message(message) - response["url"] = url - abort(403, description=response) if not student_uids: message = """To assign new students to a course, diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index d2c7cfd6..92d8710d 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -22,7 +22,7 @@ def test_post_courses(self, client, valid_course, invalid_course): assert get_response.status_code == 200 response = client.post( - "/courses?uid=Bart", json=invalid_course, + "/courses", json=invalid_course, headers={"Authorization": "teacher2"} ) # invalid course assert response.status_code == 400 @@ -32,7 +32,7 @@ def test_post_no_name(self, client, course_empty_name): Test posting a course with a blank name """ - response = client.post("/courses?uid=Bart", json=course_empty_name, + response = client.post("/courses", json=course_empty_name, headers={"Authorization": "teacher2"}) assert response.status_code == 400 def test_post_with_invalid_fields(self, client, course_invalid_field): @@ -56,11 +56,11 @@ def test_post_courses_course_id_students_and_admins( valid_students = [s.uid for s in valid_students_entries] response = client.post( - sel2_students_link + f"/students?uid={valid_course_entry.teacher}", + sel2_students_link + "/students", json={"students": valid_students}, headers={"Authorization": "teacher2"} ) - assert response.status_code == 403 + assert response.status_code == 201 def test_get_courses(self, valid_course_entries, client): """ From 0e6efe6e64d79f939320bc90c46faffc02db09aa Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Wed, 3 Apr 2024 13:05:48 +0200 Subject: [PATCH 235/377] nested translation files (#163) --- frontend/public/locales/en/translation.json | 13 +++++++++++-- frontend/public/locales/nl/translation.json | 13 +++++++++++-- frontend/src/components/Header/Header.tsx | 2 +- frontend/src/pages/home/Home.tsx | 2 +- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 1447580c..7cf28e2e 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1,7 +1,16 @@ { - "homepage": "Homepage", + "header": { "myProjects": "My Projects", "myCourses": "My Courses", "login": "Login", "home": "Home" - } \ No newline at end of file + }, + "home": { + "homepage": "Homepage" + }, + "courseForm": { + "courseName": "Course Name", + "submit": "Submit", + "emptyCourseNameError": "Course name should not be empty" + } +} \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index c852df96..a2e1f44a 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -1,7 +1,16 @@ { - "homepage": "Homepage", + "header": { "myProjects": "Mijn Projecten", "myCourses": "Mijn Vakken", "login": "Login", "home": "Home" - } \ No newline at end of file + }, + "home": { + "homepage": "Homepage" + }, + "courseForm": { + "courseName": "Vak Naam", + "submit": "Opslaan", + "emptyCourseNameError": "Vak naam mag niet leeg zijn" + } +} \ No newline at end of file diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 53846db5..dfa85964 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from "react"; * @returns JSX.Element representing the header. */ export function Header(): JSX.Element { - const { t } = useTranslation(); + const { t } = useTranslation('translation', { keyPrefix: 'header' }); const location = useLocation(); const [open, setOpen] = useState(false); const [listItems, setListItems] = useState([ diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 344fb124..662e50c6 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next"; * @returns - The home page component */ export default function Home() { - const { t } = useTranslation(); + const { t } = useTranslation('translation', { keyPrefix: 'home' }); return (

{t('homepage')}

From cbf55677d6ad268804234def997a62b864584d21 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Thu, 4 Apr 2024 13:17:49 +0200 Subject: [PATCH 236/377] return deadline aswel on projects (#168) --- backend/project/endpoints/projects/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index b0afa4f8..835e692d 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -38,7 +38,7 @@ def get(self, teacher_id=None): return query_selected_from_model( Project, response_url, - select_values=["project_id", "title", "description"], + select_values=["project_id", "title", "description", "deadline"], url_mapper={"project_id": response_url}, filters=request.args ) From e3ca8f8cc2a2c1c15f52b563ee439602229ee584 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Apr 2024 13:24:23 +0200 Subject: [PATCH 237/377] Bump vite from 5.1.3 to 5.1.7 in /frontend (#166) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.3 to 5.1.7. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.1.7/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.1.7/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 8 ++++---- frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c9f36c33..a5bab1f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,7 +38,7 @@ "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", "typescript": "^5.2.2", - "vite": "^5.1.0" + "vite": "^5.1.7" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -6149,9 +6149,9 @@ } }, "node_modules/vite": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", - "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.7.tgz", + "integrity": "sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==", "dev": true, "dependencies": { "esbuild": "^0.19.3", diff --git a/frontend/package.json b/frontend/package.json index c53f82b9..73c74866 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,6 @@ "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", "typescript": "^5.2.2", - "vite": "^5.1.0" + "vite": "^5.1.7" } } From c25c29279b82ad5d591fe0031cd1d2171c67a888 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 4 Apr 2024 13:55:40 +0200 Subject: [PATCH 238/377] Using i18n browser detector and route detection to assign languages aswell as menu for users (#156) * added tag to locales * added menu to switch between languages * updated i18n detector dependency * configured detector to detect language in path * configured language routes * added outlet that detects languague and adds it to i18n * added menu to select language * resolved linting * unused statement removed --- frontend/package-lock.json | 8 +-- frontend/package.json | 2 +- frontend/public/locales/en/translation.json | 5 +- frontend/public/locales/nl/translation.json | 7 +- frontend/src/App.tsx | 5 ++ frontend/src/components/Header/Header.tsx | 72 +++++++++++++++++---- frontend/src/components/LanguagePath.tsx | 27 ++++++++ frontend/src/i18n.js | 7 +- frontend/src/pages/home/Home.tsx | 4 +- 9 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/LanguagePath.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a5bab1f5..48e11ebc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,7 +34,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", "i18next": "^23.10.1", - "i18next-browser-languagedetector": "^7.2.0", + "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", "typescript": "^5.2.2", @@ -4074,9 +4074,9 @@ } }, "node_modules/i18next-browser-languagedetector": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz", - "integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.1.tgz", + "integrity": "sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw==", "dev": true, "dependencies": { "@babel/runtime": "^7.23.2" diff --git a/frontend/package.json b/frontend/package.json index 73c74866..268df4fd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", "i18next": "^23.10.1", - "i18next-browser-languagedetector": "^7.2.0", + "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", "typescript": "^5.2.2", diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 7cf28e2e..6c8a3568 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -3,9 +3,8 @@ "myProjects": "My Projects", "myCourses": "My Courses", "login": "Login", - "home": "Home" - }, - "home": { + "home": "Home", + "tag": "en", "homepage": "Homepage" }, "courseForm": { diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index a2e1f44a..e62cf43d 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -2,10 +2,9 @@ "header": { "myProjects": "Mijn Projecten", "myCourses": "Mijn Vakken", - "login": "Login", - "home": "Home" - }, - "home": { + "login": "Aanmelden", + "home": "Home", + "tag": "nl", "homepage": "Homepage" }, "courseForm": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 307c2751..1e17cf30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,8 @@ import { BrowserRouter,Route,Routes } from "react-router-dom"; import { Header } from "./components/Header/Header"; import Home from "./pages/home/Home"; +import LanguagePath from "./components/LanguagePath"; + /** * This component is the main application component that will be rendered by the ReactDOM. * @returns - The main application component @@ -11,6 +13,9 @@ function App(): JSX.Element {
} /> + }> + } /> + ); diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index dfa85964..f7cdb63f 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,15 +1,46 @@ -import { AppBar, Box, Button, Drawer, Grid, IconButton, List, ListItemButton, ListItemText, Toolbar, Typography } from "@mui/material"; -import { Menu } from "@mui/icons-material"; -import { useTranslation } from 'react-i18next'; -import { Link, useLocation } from 'react-router-dom'; +import { + AppBar, + Box, + Button, + IconButton, + Menu, + MenuItem, + Toolbar, + Typography, + Drawer, + Grid, + List, + ListItemButton, + ListItemText +} from "@mui/material"; +import MenuIcon from "@mui/icons-material/Menu"; +import { useTranslation } from "react-i18next"; import { useEffect, useState } from "react"; +import LanguageIcon from "@mui/icons-material/Language"; +import { Link, useLocation } from "react-router-dom"; /** - * Renders the header component. - * @returns JSX.Element representing the header. + * The header component for the application that will be rendered at the top of the page. + * @returns - The header component */ export function Header(): JSX.Element { - const { t } = useTranslation('translation', { keyPrefix: 'header' }); + const { t, i18n } = useTranslation('translation', { keyPrefix: 'header' }); + const [languageMenuAnchor, setLanguageMenuAnchor] = + useState(null); + + const handleLanguageMenu = (event: React.MouseEvent) => { + setLanguageMenuAnchor(event.currentTarget); + }; + + const handleChangeLanguage = (language: string) => { + i18n.changeLanguage(language); + setLanguageMenuAnchor(null); + }; + + const handleCloseLanguageMenu = () => { + setLanguageMenuAnchor(null); + }; + const location = useLocation(); const [open, setOpen] = useState(false); const [listItems, setListItems] = useState([ @@ -37,13 +68,32 @@ export function Header(): JSX.Element { setOpen(!open)} sx={{ color: "white", marginLeft: 0 }}> - + {title} - - + +
+ + + + {t("tag")} + + + + handleChangeLanguage("en")}> + English + + handleChangeLanguage("nl")}> + Nederlands + + +
setOpen(false)} listItems={listItems}/> @@ -93,7 +143,7 @@ function DrawerMenu({ open, onClose, listItems }: { open: boolean, onClose: () = - + diff --git a/frontend/src/components/LanguagePath.tsx b/frontend/src/components/LanguagePath.tsx new file mode 100644 index 00000000..fe6eccb6 --- /dev/null +++ b/frontend/src/components/LanguagePath.tsx @@ -0,0 +1,27 @@ +// https://stackoverflow.com/questions/71769484/react-router-v6-nested-routes-with-i18n +import { useEffect } from "react"; +import { Outlet, useParams, useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +const SUPPORTED_LANGUAGES = ["en", "nl"]; + +/** + * + * @returns - An outlet that detects the language and assigns it to i18next directly. + */ +export default function LanguagePath() { + const { i18n } = useTranslation(); + const { lang } = useParams(); + const navigate = useNavigate(); + const curPath = location.pathname; + useEffect(() => { + if (lang && i18n.resolvedLanguage !== lang) { + if (SUPPORTED_LANGUAGES.includes(lang)) { + i18n.changeLanguage(lang); + } else { + navigate("/" + i18n.resolvedLanguage + curPath, { replace: true }); + } + } + }, [lang, curPath, i18n, navigate]); + return ; +} diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index 98055d4a..0c5090bb 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -3,6 +3,11 @@ import { initReactI18next } from 'react-i18next'; import Backend from 'i18next-http-backend'; import LanguageDetector from 'i18next-browser-languagedetector'; +const detectionOptions = { + order: ['path', 'navigator', 'localStorage', 'subdomain', 'queryString', 'htmlTag'], + lookupFromPathIndex: 0 +} + i18n .use(Backend) .use(LanguageDetector) @@ -10,7 +15,7 @@ i18n .init({ fallbackLng: 'en', debug: true, - + detection: detectionOptions, interpolation: { escapeValue: false, } diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 662e50c6..03358a3d 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,14 +1,12 @@ -import { useTranslation } from "react-i18next"; /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { - const { t } = useTranslation('translation', { keyPrefix: 'home' }); return (
-

{t('homepage')}

+

); } From 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:19:26 +0200 Subject: [PATCH 239/377] Frontend/feature/submission post (#150) * added dependencies for treeview and code viewer * created drag and drop upload, treeview and code viewer * Fix #10 * removed unused dependencies * created files utils with zip regex checker * moved function to utils * removed unused eslint rule * corrected message on missing required files * consistent frontend text * fixed grammar * resolved package diff --- frontend/.eslintrc.cjs | 1 - frontend/package-lock.json | 1158 +++++++++-------- frontend/package.json | 2 + .../components/FolderUpload/FolderUpload.tsx | 131 ++ frontend/src/utils/file-utils.ts | 42 + 5 files changed, 821 insertions(+), 513 deletions(-) create mode 100644 frontend/src/components/FolderUpload/FolderUpload.tsx create mode 100644 frontend/src/utils/file-utils.ts diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 0229c600..3cd9c9a6 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -40,7 +40,6 @@ module.exports = { "jsdoc/require-param": 0, "jsdoc/require-param-description": 1, "jsdoc/require-param-name": 1, - "jsdoc/require-param-type": 0, "jsdoc/require-property": 1, "jsdoc/require-property-description": 1, "jsdoc/require-property-name": 1, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 48e11ebc..09f79e1d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,10 +15,12 @@ "@mui/styled-engine-sc": "^6.0.0-alpha.16", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", + "jszip": "^3.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", "react-router-dom": "^6.22.1", + "stream": "^0.0.2", "styled-components": "^6.1.8" }, "devDependencies": { @@ -51,55 +53,55 @@ } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", - "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.4", "@babel/helper-compilation-targets": "^7.23.6", "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.9", - "@babel/parser": "^7.23.9", - "@babel/template": "^7.23.9", - "@babel/traverse": "^7.23.9", - "@babel/types": "^7.23.9", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -114,6 +116,12 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -124,14 +132,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", "dev": true, "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { @@ -198,11 +206,11 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", + "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -228,9 +236,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", + "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", "dev": true, "engines": { "node": ">=6.9.0" @@ -261,9 +269,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", "engines": { "node": ">=6.9.0" } @@ -286,36 +294,37 @@ } }, "node_modules/@babel/helpers": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.9.tgz", - "integrity": "sha512-87ICKgU5t5SzOT7sBMfCOZQ2rHjRU+Pcb9BoILMYz600W6DkVRLFBPwQ18gwUVvggqXivaUakpnxWQGbpywbBQ==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", "dev": true, "dependencies": { - "@babel/template": "^7.23.9", - "@babel/traverse": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", "dependencies": { "@babel/helper-validator-identifier": "^7.22.20", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -325,12 +334,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.23.3.tgz", - "integrity": "sha512-qXRvbeKDSfwnlJnanVRp0SfuWE5DQhwQr5xtLBzp56Wabyo+4CMosF6Kfp+eOD/4FYpql64XVJ2W0pVLlJZxOQ==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.1.tgz", + "integrity": "sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -340,12 +349,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.23.3.tgz", - "integrity": "sha512-91RS0MDnAWDNvGC6Wio5XYkyWI39FMFO+JK9+4AlgaTH+yWwVTsw7/sn6LK0lH7c5F+TFkpv/3LfCJ1Ydwof/g==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz", + "integrity": "sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" @@ -355,9 +364,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", - "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", + "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -366,33 +375,33 @@ } }, "node_modules/@babel/template": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", - "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", "dev": true, "dependencies": { "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", - "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -401,9 +410,9 @@ } }, "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -489,22 +498,6 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" - }, - "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@emotion/cache": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", @@ -523,9 +516,9 @@ "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", - "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", "dependencies": { "@emotion/memoize": "^0.8.1" } @@ -536,9 +529,9 @@ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" }, "node_modules/@emotion/react": { - "version": "11.11.3", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", - "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", + "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -559,9 +552,9 @@ } }, "node_modules/@emotion/serialize": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", - "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.4.tgz", + "integrity": "sha512-RIN04MBT8g+FnDwgvIUi8czvr1LU1alUMI05LekWB5DGyTm8cCBMCRpq3GqaiyEDRptEXOyXnvZ58GZYu4kBxQ==", "dependencies": { "@emotion/hash": "^0.9.1", "@emotion/memoize": "^0.8.1", @@ -570,25 +563,20 @@ "csstype": "^3.0.2" } }, - "node_modules/@emotion/serialize/node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" - }, "node_modules/@emotion/sheet": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" }, "node_modules/@emotion/styled": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", - "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "version": "11.11.5", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.5.tgz", + "integrity": "sha512-/ZjjnaNKvuMPxcIiUkf/9SHoG4Q196DRl1w82hQ3WCsjo1IUR8uaGWrC6a87CrYAW0Kb/pK7hk8BnLgLRi9KoQ==", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", - "@emotion/is-prop-valid": "^1.2.1", - "@emotion/serialize": "^1.1.2", + "@emotion/is-prop-valid": "^1.2.2", + "@emotion/serialize": "^1.1.4", "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", "@emotion/utils": "^1.2.1" }, @@ -603,9 +591,9 @@ } }, "node_modules/@emotion/unitless": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", - "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.0.1", @@ -640,9 +628,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", "cpu": [ "ppc64" ], @@ -656,9 +644,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", "cpu": [ "arm" ], @@ -672,9 +660,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", "cpu": [ "arm64" ], @@ -688,9 +676,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", "cpu": [ "x64" ], @@ -704,9 +692,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", "cpu": [ "arm64" ], @@ -720,9 +708,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", "cpu": [ "x64" ], @@ -736,9 +724,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", "cpu": [ "arm64" ], @@ -752,9 +740,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", "cpu": [ "x64" ], @@ -768,9 +756,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", "cpu": [ "arm" ], @@ -784,9 +772,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", "cpu": [ "arm64" ], @@ -800,9 +788,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", "cpu": [ "ia32" ], @@ -816,9 +804,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", "cpu": [ "loong64" ], @@ -832,9 +820,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", "cpu": [ "mips64el" ], @@ -848,9 +836,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", "cpu": [ "ppc64" ], @@ -864,9 +852,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", "cpu": [ "riscv64" ], @@ -880,9 +868,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", "cpu": [ "s390x" ], @@ -896,9 +884,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", "cpu": [ "x64" ], @@ -912,9 +900,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", "cpu": [ "x64" ], @@ -928,9 +916,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", "cpu": [ "x64" ], @@ -944,9 +932,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", "cpu": [ "x64" ], @@ -960,9 +948,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", "cpu": [ "arm64" ], @@ -976,9 +964,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", "cpu": [ "ia32" ], @@ -992,9 +980,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", "cpu": [ "x64" ], @@ -1091,10 +1079,22 @@ "node": "*" } }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1184,20 +1184,20 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -1213,9 +1213,9 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -1228,9 +1228,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1269,14 +1269,14 @@ } }, "node_modules/@mui/base": { - "version": "5.0.0-beta.36", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.36.tgz", - "integrity": "sha512-6A8fYiXgjqTO6pgj31Hc8wm1M3rFYCxDRh09dBVk0L0W4cb2lnurRJa3cAyic6hHY+we1S58OdGYRbKmOsDpGQ==", + "version": "5.0.0-beta.40", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", + "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", "dependencies": { "@babel/runtime": "^7.23.9", "@floating-ui/react-dom": "^2.0.8", - "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.9", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", "@popperjs/core": "^2.11.8", "clsx": "^2.1.0", "prop-types": "^15.8.1" @@ -1300,18 +1300,18 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.10", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.10.tgz", - "integrity": "sha512-qPv7B+LeMatYuzRjB3hlZUHqinHx/fX4YFBiaS19oC02A1e9JFuDKDvlyRQQ5oRSbJJt0QlaLTlr0IcauVcJRQ==", + "version": "5.15.15", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.15.tgz", + "integrity": "sha512-aXnw29OWQ6I5A47iuWEI6qSSUfH6G/aCsW9KmW3LiFqr7uXZBK4Ks+z8G+qeIub8k0T5CMqlT2q0L+ZJTMrqpg==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" } }, "node_modules/@mui/icons-material": { - "version": "5.15.10", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.10.tgz", - "integrity": "sha512-9cF8oUHZKo9oQ7EQ3pxPELaZuZVmphskU4OI6NiJNDVN7zcuvrEsuWjYo1Zh4fLiC39Nrvm30h/B51rcUjvSGA==", + "version": "5.15.15", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.15.tgz", + "integrity": "sha512-kkeU/pe+hABcYDH6Uqy8RmIsr2S/y5bP2rp+Gat4CcRjCcVne6KudS1NrZQhUCRysrTDCAhcbcf9gt+/+pGO2g==", "dependencies": { "@babel/runtime": "^7.23.9" }, @@ -1334,16 +1334,16 @@ } }, "node_modules/@mui/material": { - "version": "5.15.10", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.10.tgz", - "integrity": "sha512-YJJGHjwDOucecjDEV5l9ISTCo+l9YeWrho623UajzoHRYxuKUmwrGVYOW4PKwGvCx9SU9oklZnbbi2Clc5XZHw==", + "version": "5.15.15", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.15.tgz", + "integrity": "sha512-3zvWayJ+E1kzoIsvwyEvkTUKVKt1AjchFFns+JtluHCuvxgKcLSRJTADw37k0doaRtVAsyh8bz9Afqzv+KYrIA==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/base": "5.0.0-beta.36", - "@mui/core-downloads-tracker": "^5.15.10", - "@mui/system": "^5.15.9", - "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.9", + "@mui/base": "5.0.0-beta.40", + "@mui/core-downloads-tracker": "^5.15.15", + "@mui/system": "^5.15.15", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", "@types/react-transition-group": "^4.4.10", "clsx": "^2.1.0", "csstype": "^3.1.3", @@ -1378,12 +1378,12 @@ } }, "node_modules/@mui/private-theming": { - "version": "5.15.9", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.9.tgz", - "integrity": "sha512-/aMJlDOxOTAXyp4F2rIukW1O0anodAMCkv1DfBh/z9vaKHY3bd5fFf42wmP+0GRmwMinC5aWPpNfHXOED1fEtg==", + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", + "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.9", + "@mui/utils": "^5.15.14", "prop-types": "^15.8.1" }, "engines": { @@ -1404,9 +1404,9 @@ } }, "node_modules/@mui/styled-engine": { - "version": "5.15.9", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.9.tgz", - "integrity": "sha512-NRKtYkL5PZDH7dEmaLEIiipd3mxNnQSO+Yo8rFNBNptY8wzQnQ+VjayTq39qH7Sast5cwHKYFusUrQyD+SS4Og==", + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", + "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", "dependencies": { "@babel/runtime": "^7.23.9", "@emotion/cache": "^11.11.0", @@ -1435,9 +1435,9 @@ } }, "node_modules/@mui/styled-engine-sc": { - "version": "6.0.0-alpha.16", - "resolved": "https://registry.npmjs.org/@mui/styled-engine-sc/-/styled-engine-sc-6.0.0-alpha.16.tgz", - "integrity": "sha512-hZT4xPk/zvFtNvUS/VumrtSe+sV2eZB2Z048z0RGgxMDLTGgWAIs4kJ+YAS16ugmE2nwrlaZhpfr/1lD7gd7xg==", + "version": "6.0.0-alpha.18", + "resolved": "https://registry.npmjs.org/@mui/styled-engine-sc/-/styled-engine-sc-6.0.0-alpha.18.tgz", + "integrity": "sha512-W3mqR1K01rPL0BVNTgGpIYxdbQ/nTAlwYaohRdmX7FZvbm1yKw9F90OIGxM503dfRMVBi6a/neYPgIUebcGsHw==", "dependencies": { "@babel/runtime": "^7.23.9", "csstype": "^3.1.3", @@ -1456,15 +1456,15 @@ } }, "node_modules/@mui/system": { - "version": "5.15.9", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.9.tgz", - "integrity": "sha512-SxkaaZ8jsnIJ77bBXttfG//LUf6nTfOcaOuIgItqfHv60ZCQy/Hu7moaob35kBb+guxVJnoSZ+7vQJrA/E7pKg==", + "version": "5.15.15", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.15.tgz", + "integrity": "sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.9", - "@mui/styled-engine": "^5.15.9", - "@mui/types": "^7.2.13", - "@mui/utils": "^5.15.9", + "@mui/private-theming": "^5.15.14", + "@mui/styled-engine": "^5.15.14", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -1495,9 +1495,9 @@ } }, "node_modules/@mui/types": { - "version": "7.2.13", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", - "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", + "version": "7.2.14", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", + "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0" }, @@ -1508,9 +1508,9 @@ } }, "node_modules/@mui/utils": { - "version": "5.15.9", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.9.tgz", - "integrity": "sha512-yDYfr61bCYUz1QtwvpqYy/3687Z8/nS4zv7lv/ih/6ZFGMl1iolEvxRmR84v2lOYxlds+kq1IVYbXxDKh8Z9sg==", + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", + "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", "dependencies": { "@babel/runtime": "^7.23.9", "@types/prop-types": "^15.7.11", @@ -1579,17 +1579,17 @@ } }, "node_modules/@remix-run/router": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.1.tgz", - "integrity": "sha512-zcU0gM3z+3iqj8UX45AmWY810l3oUmXM7uH4dt5xtzvMhRtYVhKGOmgOd1877dOPPepfCjUv57w+syamWIYe7w==", + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", + "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", "engines": { "node": ">=14.0.0" } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", - "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.0.tgz", + "integrity": "sha512-jwXtxYbRt1V+CdQSy6Z+uZti7JF5irRKF8hlKfEnF/xJpcNGuuiZMBvuoYM+x9sr9iWGnzrlM0+9hvQ1kgkf1w==", "cpu": [ "arm" ], @@ -1600,9 +1600,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", - "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.0.tgz", + "integrity": "sha512-fI9nduZhCccjzlsA/OuAwtFGWocxA4gqXGTLvOyiF8d+8o0fZUeSztixkYjcGq1fGZY3Tkq4yRvHPFxU+jdZ9Q==", "cpu": [ "arm64" ], @@ -1613,9 +1613,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", - "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.0.tgz", + "integrity": "sha512-BcnSPRM76/cD2gQC+rQNGBN6GStBs2pl/FpweW8JYuz5J/IEa0Fr4AtrPv766DB/6b2MZ/AfSIOSGw3nEIP8SA==", "cpu": [ "arm64" ], @@ -1626,9 +1626,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", - "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.0.tgz", + "integrity": "sha512-LDyFB9GRolGN7XI6955aFeI3wCdCUszFWumWU0deHA8VpR3nWRrjG6GtGjBrQxQKFevnUTHKCfPR4IvrW3kCgQ==", "cpu": [ "x64" ], @@ -1639,9 +1639,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", - "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.0.tgz", + "integrity": "sha512-ygrGVhQP47mRh0AAD0zl6QqCbNsf0eTo+vgwkY6LunBcg0f2Jv365GXlDUECIyoXp1kKwL5WW6rsO429DBY/bA==", "cpu": [ "arm" ], @@ -1652,9 +1652,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", - "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.0.tgz", + "integrity": "sha512-x+uJ6MAYRlHGe9wi4HQjxpaKHPM3d3JjqqCkeC5gpnnI6OWovLdXTpfa8trjxPLnWKyBsSi5kne+146GAxFt4A==", "cpu": [ "arm64" ], @@ -1665,9 +1665,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", - "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.0.tgz", + "integrity": "sha512-nrRw8ZTQKg6+Lttwqo6a2VxR9tOroa2m91XbdQ2sUUzHoedXlsyvY1fN4xWdqz8PKmf4orDwejxXHjh7YBGUCA==", "cpu": [ "arm64" ], @@ -1677,10 +1677,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.0.tgz", + "integrity": "sha512-xV0d5jDb4aFu84XKr+lcUJ9y3qpIWhttO3Qev97z8DKLXR62LC3cXT/bMZXrjLF9X+P5oSmJTzAhqwUbY96PnA==", + "cpu": [ + "ppc64le" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", - "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.0.tgz", + "integrity": "sha512-SDDhBQwZX6LPRoPYjAZWyL27LbcBo7WdBFWJi5PI9RPCzU8ijzkQn7tt8NXiXRiFMJCVpkuMkBf4OxSxVMizAw==", "cpu": [ "riscv64" ], @@ -1690,10 +1703,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.0.tgz", + "integrity": "sha512-RxB/qez8zIDshNJDufYlTT0ZTVut5eCpAZ3bdXDU9yTxBzui3KhbGjROK2OYTTor7alM7XBhssgoO3CZ0XD3qA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", - "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.0.tgz", + "integrity": "sha512-C6y6z2eCNCfhZxT9u+jAM2Fup89ZjiG5pIzZIDycs1IwESviLxwkQcFRGLjnDrP+PT+v5i4YFvlcfAs+LnreXg==", "cpu": [ "x64" ], @@ -1704,9 +1730,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", - "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.0.tgz", + "integrity": "sha512-i0QwbHYfnOMYsBEyjxcwGu5SMIi9sImDVjDg087hpzXqhBSosxkE7gyIYFHgfFl4mr7RrXksIBZ4DoLoP4FhJg==", "cpu": [ "x64" ], @@ -1717,9 +1743,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", - "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.0.tgz", + "integrity": "sha512-Fq52EYb0riNHLBTAcL0cun+rRwyZ10S9vKzhGKKgeD+XbwunszSY0rVMco5KbOsTlwovP2rTOkiII/fQ4ih/zQ==", "cpu": [ "arm64" ], @@ -1730,9 +1756,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", - "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.0.tgz", + "integrity": "sha512-e/PBHxPdJ00O9p5Ui43+vixSgVf4NlLsmV6QneGERJ3lnjIua/kim6PRFe3iDueT1rQcgSkYP8ZBBXa/h4iPvw==", "cpu": [ "ia32" ], @@ -1743,9 +1769,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", - "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.0.tgz", + "integrity": "sha512-aGg7iToJjdklmxlUlJh/PaPNa4PmqHfyRMLunbL3eaMO0gp656+q1zOKkpJ/CVe9CryJv6tAN1HDoR8cNGzkag==", "cpu": [ "x64" ], @@ -1809,9 +1835,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz", - "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==", + "version": "20.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.4.tgz", + "integrity": "sha512-E+Fa9z3wSQpzgYQdYmme5X3OTuejnnTx88A6p6vkkJosR3KBz+HpE3kqNm98VE6cfLFcISx7zW7MsJkH6KwbTw==", "dev": true, "optional": true, "dependencies": { @@ -1824,24 +1850,23 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" }, "node_modules/@types/prop-types": { - "version": "15.7.11", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/react": { - "version": "18.2.56", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.56.tgz", - "integrity": "sha512-NpwHDMkS/EFZF2dONFQHgkPRwhvgq/OAvIaGQzxGSBmaeR++kTg6njr15Vatz0/2VcCEwJQFi6Jf4Q0qBu0rLA==", + "version": "18.2.74", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz", + "integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==", "dependencies": { "@types/prop-types": "*", - "@types/scheduler": "*", "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "18.2.19", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.19.tgz", - "integrity": "sha512-aZvQL6uUbIJpjZk4U8JZGbau9KDeAwMfmhyWorxgBkqDIEf6ROjRozcmPIicqsUwPUjbkDfHKgGee1Lq65APcA==", + "version": "18.2.24", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.24.tgz", + "integrity": "sha512-cN6upcKd8zkGy4HU9F1+/s98Hrp6D4MOcippK4PoE8OZRngohHZpbJn1GsaDLz87MqvHNoT13nHvNqM9ocRHZg==", "dev": true, "dependencies": { "@types/react": "*" @@ -1855,15 +1880,10 @@ "@types/react": "*" } }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" - }, "node_modules/@types/semver": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz", - "integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==", + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, "node_modules/@types/sinonjs__fake-timers": { @@ -2182,18 +2202,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2525,9 +2533,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001588", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001588.tgz", - "integrity": "sha512-+hVY9jE44uKLkH0SrUTqxjxqNTOWHsbnQDIKjwkZ3lNTzUUVdBLBGXtj/q5Mp5u98r3droaZAewQuEDzjQdZlQ==", + "version": "1.0.30001605", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001605.tgz", + "integrity": "sha512-nXwGlFWo34uliI9z3n6Qc0wZaf7zaZWA1CPZ169La5mV3I/gem7bst0vr5XQH5TJXZIMfDeZyOrZnSlVzKxxHQ==", "dev": true, "funding": [ { @@ -2563,6 +2571,14 @@ "node": ">=4" } }, + "node_modules/chalk/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -2609,9 +2625,9 @@ } }, "node_modules/cli-table3": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.4.tgz", + "integrity": "sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==", "dev": true, "dependencies": { "string-width": "^4.2.0" @@ -2712,16 +2728,14 @@ "dev": true }, "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/cosmiconfig": { "version": "7.1.0", @@ -2785,9 +2799,9 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cypress": { - "version": "13.6.4", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.4.tgz", - "integrity": "sha512-pYJjCfDYB+hoOoZuhysbbYhEmNW7DEDsqn+ToCLwuVowxUXppIWRr7qk4TVRIU471ksfzyZcH+mkoF0CQUKnpw==", + "version": "13.7.2", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.7.2.tgz", + "integrity": "sha512-FF5hFI5wlRIHY8urLZjJjj/YvfCBrRpglbZCLr/cYcL9MdDe0+5usa8kTIrDHthlEc9lwihbkb5dmwqBDNS2yw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -2798,7 +2812,7 @@ "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", - "buffer": "^5.6.0", + "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", "check-more-types": "^2.24.0", @@ -2816,7 +2830,7 @@ "figures": "^3.2.0", "fs-extra": "^9.1.0", "getos": "^3.2.1", - "is-ci": "^3.0.0", + "is-ci": "^3.0.1", "is-installed-globally": "~0.4.0", "lazy-ass": "^1.6.0", "listr2": "^3.8.3", @@ -3037,11 +3051,19 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.673", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.673.tgz", - "integrity": "sha512-zjqzx4N7xGdl5468G+vcgzDhaHkaYgVcf9MqgexcTqsl2UHSCmOj/Bi3HAprg4BZCpC7HyD8a6nZl6QAZf72gw==", + "version": "1.4.726", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.726.tgz", + "integrity": "sha512-xtjfBXn53RORwkbyKvDfTajtnTp0OJoPOIBzXvkNbb7+YYvCHJflba3L7Txyx/6Fov3ov2bGPr/n5MTixmPhdQ==", "dev": true }, + "node_modules/emitter-component": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz", + "integrity": "sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3100,9 +3122,9 @@ } }, "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "dev": true, "hasInstallScript": true, "bin": { @@ -3112,29 +3134,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" } }, "node_modules/escalade": { @@ -3147,24 +3169,27 @@ } }, "node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "engines": { - "node": ">=0.8.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.56.0", - "@humanwhocodes/config-array": "^0.11.13", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -3210,9 +3235,9 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "48.1.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.1.0.tgz", - "integrity": "sha512-g9S8ukmTd1DVcV/xeBYPPXOZ6rc8WJ4yi0+MVxJ1jBOrz5kmxV9gJJQ64ltCqIWFnBChLIhLVx3tbTSarqVyFA==", + "version": "48.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.2.tgz", + "integrity": "sha512-S0Gk+rpT5w/ephKCncUY7kUsix9uE4B9XI8D/fS1/26d8okE+vZsuG1IvIt4B6sJUdQqsnzi+YXfmh+HJG11CA==", "dev": true, "dependencies": { "@es-joy/jsdoccomment": "~0.42.0", @@ -3232,18 +3257,6 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, - "node_modules/eslint-plugin-jsdoc/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint-plugin-react-hooks": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", @@ -3257,9 +3270,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.5.tgz", - "integrity": "sha512-D53FYKJa+fDmZMtriODxvhwrO+IOqrxoEo21gMA0sjHdU6dPVH4OhyFip9ypl8HOF5RV5KdTo+rBQLvnY2cO8w==", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.6.tgz", + "integrity": "sha512-NjGXdm7zgcKRkKMua34qVO9doI7VOxZ6ancSvBELJSSoX97jyndXcSoa8XBh69JoB31dNz3EEzlMcizZl7LaMA==", "dev": true, "peerDependencies": { "eslint": ">=7" @@ -3362,18 +3375,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -3422,6 +3423,18 @@ "node": ">=8" } }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -3636,6 +3649,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -3696,9 +3718,9 @@ } }, "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, "node_modules/forever-agent": { @@ -3971,9 +3993,9 @@ } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "dev": true, "engines": { "node": ">= 0.4" @@ -3995,9 +4017,9 @@ } }, "node_modules/hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -4120,6 +4142,11 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -4166,8 +4193,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "2.0.0", @@ -4315,6 +4341,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4451,6 +4482,17 @@ "verror": "1.10.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4482,6 +4524,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4993,6 +5043,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5100,10 +5155,9 @@ } }, "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", @@ -5119,7 +5173,7 @@ } ], "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -5162,6 +5216,11 @@ "node": ">= 0.6.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5309,11 +5368,11 @@ } }, "node_modules/react-router": { - "version": "6.22.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.1.tgz", - "integrity": "sha512-0pdoRGwLtemnJqn1K0XHUbnKiX0S4X8CgvVVmHGOWmofESj31msHo/1YiqcJWK7Wxfq2a4uvvtS01KAQyWK/CQ==", + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", + "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", "dependencies": { - "@remix-run/router": "1.15.1" + "@remix-run/router": "1.15.3" }, "engines": { "node": ">=14.0.0" @@ -5323,12 +5382,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.22.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.1.tgz", - "integrity": "sha512-iwMyyyrbL7zkKY7MRjOVRy+TMnS/OPusaFVxM2P11x9dzSzGmLsebkCvYirGq0DWB9K9hOspHYYtDz33gE5Duw==", + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", + "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", "dependencies": { - "@remix-run/router": "1.15.1", - "react-router": "6.22.1" + "@remix-run/router": "1.15.3", + "react-router": "6.22.3" }, "engines": { "node": ">=14.0.0" @@ -5353,6 +5412,25 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -5442,9 +5520,9 @@ } }, "node_modules/rollup": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", - "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.0.tgz", + "integrity": "sha512-Qe7w62TyawbDzB4yt32R0+AbIo6m1/sqO7UPzFS8Z/ksL5mrfhA0v4CavfdmFav3D+ub4QeAgsGEe84DoWe/nQ==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -5457,19 +5535,21 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.12.0", - "@rollup/rollup-android-arm64": "4.12.0", - "@rollup/rollup-darwin-arm64": "4.12.0", - "@rollup/rollup-darwin-x64": "4.12.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", - "@rollup/rollup-linux-arm64-gnu": "4.12.0", - "@rollup/rollup-linux-arm64-musl": "4.12.0", - "@rollup/rollup-linux-riscv64-gnu": "4.12.0", - "@rollup/rollup-linux-x64-gnu": "4.12.0", - "@rollup/rollup-linux-x64-musl": "4.12.0", - "@rollup/rollup-win32-arm64-msvc": "4.12.0", - "@rollup/rollup-win32-ia32-msvc": "4.12.0", - "@rollup/rollup-win32-x64-msvc": "4.12.0", + "@rollup/rollup-android-arm-eabi": "4.14.0", + "@rollup/rollup-android-arm64": "4.14.0", + "@rollup/rollup-darwin-arm64": "4.14.0", + "@rollup/rollup-darwin-x64": "4.14.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.14.0", + "@rollup/rollup-linux-arm64-gnu": "4.14.0", + "@rollup/rollup-linux-arm64-musl": "4.14.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.14.0", + "@rollup/rollup-linux-riscv64-gnu": "4.14.0", + "@rollup/rollup-linux-s390x-gnu": "4.14.0", + "@rollup/rollup-linux-x64-gnu": "4.14.0", + "@rollup/rollup-linux-x64-musl": "4.14.0", + "@rollup/rollup-win32-arm64-msvc": "4.14.0", + "@rollup/rollup-win32-ia32-msvc": "4.14.0", + "@rollup/rollup-win32-x64-msvc": "4.14.0", "fsevents": "~2.3.2" } }, @@ -5573,22 +5653,27 @@ "dev": true }, "node_modules/set-function-length": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", - "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "dependencies": { - "define-data-property": "^1.1.2", + "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.3", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -5616,12 +5701,12 @@ } }, "node_modules/side-channel": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", - "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dev": true, "dependencies": { - "call-bind": "^1.0.6", + "call-bind": "^1.0.7", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.4", "object-inspect": "^1.13.1" @@ -5704,9 +5789,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -5758,6 +5843,27 @@ "node": ">=0.10.0" } }, + "node_modules/stream": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", + "integrity": "sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g==", + "dependencies": { + "emitter-component": "^1.1.1" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5832,43 +5938,34 @@ "react-dom": ">= 16.8.0" } }, + "node_modules/styled-components/node_modules/@emotion/is-prop-valid": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", + "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/styled-components/node_modules/@emotion/unitless": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", + "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + }, "node_modules/styled-components/node_modules/csstype": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" }, - "node_modules/styled-components/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/styled-components/node_modules/stylis": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -5918,15 +6015,12 @@ "dev": true }, "node_modules/tmp": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", - "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", "dev": true, - "dependencies": { - "rimraf": "^3.0.0" - }, "engines": { - "node": ">=8.17.0" + "node": ">=14.14" } }, "node_modules/to-fast-properties": { @@ -5980,9 +6074,9 @@ "dev": true }, "node_modules/ts-api-utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", - "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", "dev": true, "engines": { "node": ">=16" @@ -5992,9 +6086,10 @@ } }, "node_modules/tslib": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -6027,9 +6122,9 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "engines": { "node": ">=10" @@ -6039,9 +6134,9 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -6125,6 +6220,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -6148,15 +6248,21 @@ "extsprintf": "^1.2.0" } }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true + }, "node_modules/vite": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.7.tgz", - "integrity": "sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==", + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz", + "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==", "dev": true, "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.35", - "rollup": "^4.2.0" + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" }, "bin": { "vite": "bin/vite.js" @@ -6203,6 +6309,34 @@ } } }, + "node_modules/vite/node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 268df4fd..825d4653 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,10 +19,12 @@ "@mui/styled-engine-sc": "^6.0.0-alpha.16", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", + "jszip": "^3.10.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", "react-router-dom": "^6.22.1", + "stream": "^0.0.2", "styled-components": "^6.1.8" }, "devDependencies": { diff --git a/frontend/src/components/FolderUpload/FolderUpload.tsx b/frontend/src/components/FolderUpload/FolderUpload.tsx new file mode 100644 index 00000000..463ddb97 --- /dev/null +++ b/frontend/src/components/FolderUpload/FolderUpload.tsx @@ -0,0 +1,131 @@ +import { Button, Grid, Paper, Typography, styled } from "@mui/material"; +import { verifyZipContents, getFileExtension } from "../../utils/file-utils"; +import JSZip from "jszip"; +import React, { useState } from "react"; + +interface FolderDragDropProps { + onFileDrop?: (file: File) => void; + regexRequirements?: string[]; + onWrongInput?: (message: string) => void; +} + +const supportedFileTypes = ["application/x-zip-compressed", "application/zip"]; + +const FolderDragDrop: React.FC = ({ + onFileDrop, + regexRequirements, + onWrongInput, +}) => { + const [isDraggingOver, setIsDraggingOver] = useState(false); + + const VisuallyHiddenInput = styled("input")({ + clip: "rect(0 0 0 0)", + clipPath: "inset(50%)", + height: 1, + overflow: "hidden", + position: "absolute", + bottom: 0, + left: 0, + whiteSpace: "nowrap", + width: 1, + }); + + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(true); + }; + + const handleDragLeave = () => { + setIsDraggingOver(false); + }; + + const handleNewFile = async (entry: File) => { + if (onFileDrop && supportedFileTypes.includes(entry.type)) { + const fileName: string = entry.name; + const fileExtension: string = getFileExtension(fileName); + if (fileExtension === "zip" && regexRequirements) { + try { + const regexReport = await JSZip.loadAsync(entry).then((zip) => { + return verifyZipContents(zip, regexRequirements); + }); + if (regexReport.isValid) { + onFileDrop(entry); + } else { + onWrongInput && + onWrongInput( + `Missing required files: ${regexReport.missingFiles.join( + ", " + )}.` + ); + } + } catch { + onWrongInput && + onWrongInput("Something went wrong parsing your zip."); + } + } else { + onFileDrop(entry); + } + } else { + onWrongInput && onWrongInput("The file must be zipped."); + } + }; + + const handleDrop = async (event: React.DragEvent) => { + event.preventDefault(); + setIsDraggingOver(false); + + const items = event.dataTransfer?.items; + if (items && onFileDrop) { + const folderItem = items[0]; + if (folderItem.kind === "file") { + const entry = folderItem.getAsFile(); + if (entry) { + handleNewFile(entry); + } else { + onWrongInput && + onWrongInput("Something went wrong getting your file."); + } + } else { + onWrongInput && onWrongInput("Your input must be a file."); + } + } + }; + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + handleNewFile(file); + } + } + + return ( + + + + Drag & Drop a File Here + + + + + ); +}; + +export default FolderDragDrop; diff --git a/frontend/src/utils/file-utils.ts b/frontend/src/utils/file-utils.ts new file mode 100644 index 00000000..8fa2cfe5 --- /dev/null +++ b/frontend/src/utils/file-utils.ts @@ -0,0 +1,42 @@ +import JSZip from "jszip"; + +type FileCheckResult = { + isValid: boolean; + missingFiles: string[]; +}; + +/** + * Checks if the zipObject contains at least all the files that match the regexList + * @param zipObject - JSZip object to match the regex against + * @param regexList - List of regex strings to match against the zipObject + * @returns true if all regex strings are found in the zipObject, false otherwise + */ +export function verifyZipContents( + zipObject: JSZip, + regexList: string[] +): FileCheckResult { + const missingFiles: string[] = []; + const status = regexList.every((regex) => { + let found: boolean = false; + zipObject.forEach((relativePath) => { + if (new RegExp(regex).test(relativePath)) { + found = true; + } + }); + if (!found) { + missingFiles.push(regex); + } + return found; + }); + + return { isValid: status, missingFiles: missingFiles }; +} + +/** + * Gets the extension of a given filename + * @param filename - The name of the file to get the extension of + * @returns The extension of the file + */ +export function getFileExtension(filename: string) { + return filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2); +} From 24e9452200fe6c945394069b8464cb281dc8354c Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:26:02 +0200 Subject: [PATCH 240/377] Fixed inconsistently added auth tokens --- backend/tests/conftest.py | 119 ++++++++++++++++------------ backend/tests/endpoints/conftest.py | 22 ----- 2 files changed, 68 insertions(+), 73 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index cb7bc40b..9e651330 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,14 +3,81 @@ from datetime import datetime from zoneinfo import ZoneInfo from pytest import fixture +from project import create_app_with_db from project.sessionmaker import engine, Session -from project.db_in import db +from project.db_in import db, url from project.models.course import Course from project.models.user import User,Role from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission, SubmissionStatus +### CLIENT & SESSION ### +@fixture +def app(): + """Yield a Flask application instance with database""" + app = create_app_with_db(url) + yield app + +@fixture +def client(app): + """Yield a test client""" + with app.test_client() as client: + with app.app_context(): + yield client + +@fixture +def session(): + """Yield a database session for other fixtures to use""" + session = Session() + try: + # Create all tables + db.metadata.create_all(engine) + + # (OLD) Populate the database + session.add_all(users()) + session.commit() + session.add_all(courses()) + session.commit() + session.add_all(course_relations(session)) + session.commit() + session.add_all(projects(session)) + session.commit() + session.add_all(submissions(session)) + session.commit() + + yield session + finally: + # Rollback + session.rollback() + + # Drop all tables + for table in reversed(db.metadata.sorted_tables): + session.execute(table.delete()) + session.commit() + session.close() + +### AUTHENTICATION & AUTHORIZATION ### +@fixture(autouse=True) # Always run this before a test +def auth_tokens(session): + """Add the authenticated users to the database""" + + session.add_all([ + User(uid="login", role=Role.STUDENT), + User(uid="student", role=Role.STUDENT), + User(uid="student_other", role=Role.STUDENT), + User(uid="teacher", role=Role.TEACHER), + User(uid="teacher_other", role=Role.TEACHER), + User(uid="admin", role=Role.ADMIN), + User(uid="admin_other", role=Role.ADMIN) + ]) + session.commit() + + + + + +### OLD ### @fixture def db_session(): """Create a new database session for a test. @@ -122,53 +189,3 @@ def submissions(session): submission_status= SubmissionStatus.SUCCESS ) ] - -### AUTHENTICATION & AUTHORIZATION ### -def auth_tokens(): - """Add the authenticated users to the database""" - - return [ - User(uid="login", role=Role.STUDENT), - User(uid="student", role=Role.STUDENT), - User(uid="student_other", role=Role.STUDENT), - User(uid="teacher", role=Role.TEACHER), - User(uid="teacher_other", role=Role.TEACHER), - User(uid="admin", role=Role.ADMIN), - User(uid="admin_other", role=Role.ADMIN) - ] - -### SESSION ### -@fixture -def session(): - """Create a new database session for a test. - After the test, all changes are rolled back and the session is closed.""" - - db.metadata.create_all(engine) - session = Session() - - try: - session.add_all(auth_tokens()) - session.commit() - - # Populate the database - session.add_all(users()) - session.commit() - session.add_all(courses()) - session.commit() - session.add_all(course_relations(session)) - session.commit() - session.add_all(projects(session)) - session.commit() - session.add_all(submissions(session)) - session.commit() - - yield session - finally: - # Rollback - session.rollback() - session.close() - - # Truncate all tables - for table in reversed(db.metadata.sorted_tables): - session.execute(table.delete()) - session.commit() diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 389b4293..62dae1a5 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -8,13 +8,9 @@ import pytest from pytest import fixture, FixtureRequest from flask.testing import FlaskClient -from sqlalchemy import create_engine -from sqlalchemy.exc import SQLAlchemyError from project.models.user import User,Role from project.models.course import Course from project.models.course_share_code import CourseShareCode -from project import create_app_with_db -from project.db_in import url, db from project.models.submission import Submission, SubmissionStatus from project.models.project import Project @@ -186,17 +182,6 @@ def files(): with open(name02, "rb") as temp02: yield [(temp01, name01), (temp02, name02)] -@pytest.fixture -def app(): - """A fixture that creates and configures a new app instance for each test. - Returns: - Flask -- A Flask application instance - """ - engine = create_engine(url) - app = create_app_with_db(url) - db.metadata.create_all(engine) - yield app - @pytest.fixture def course_teacher_ad(): """A user that's a teacher for testing""" @@ -240,13 +225,6 @@ def api_url(): """Get the API URL from the environment.""" return os.getenv("API_HOST") -@pytest.fixture -def client(app): - """Returns client for testing requests to the app.""" - with app.test_client() as client: - with app.app_context(): - yield client - @pytest.fixture def course_no_name(valid_teacher_entry): """A course with no name""" From 63420f7bc6f7ee085ebbb8745cf40497d920b16f Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:27:07 +0200 Subject: [PATCH 241/377] Whoops added it again --- backend/run_tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/run_tests.sh b/backend/run_tests.sh index 69bf531a..a35d5cb2 100755 --- a/backend/run_tests.sh +++ b/backend/run_tests.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash # Run Docker Compose to build and start the services, and capture the exit code from the test runner service docker-compose -f tests.yaml up --build --exit-code-from test-runner From 898bd973ba0b282c03930d85051dda949a002b7c Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:45:44 +0200 Subject: [PATCH 242/377] Adding commented out tests --- .../tests/endpoints/course/courses_test.py | 493 ++++++------------ 1 file changed, 155 insertions(+), 338 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 1ec197ea..f2aa0d3f 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -13,14 +13,17 @@ class TestCourseEndpoint(TestEndpoint): # Where is login required # (endpoint, parameters, methods) authentication = authentication_tests([ - ("/courses", [], ["get", "post"]) + ("/courses", [], ["get", "post"]), + ("/courses/@0", ["course_id"], ["get", "patch", "delete"]) ]) # Who can access what # (endpoint, parameters, method, allowed, disallowed) authorization = authorization_tests([ ("/courses", [], "get", ["student", "teacher", "admin"], []), - ("/courses", [], "post", ["teacher"], ["student", "admin"]) + ("/courses", [], "post", ["teacher"], ["student", "admin"]), + ("/courses/@0", ["course_id"], "patch", ["teacher"], ["student", "teacher_other", "admin"]), + ("/courses/@0", ["course_id"], "delete", ["teacher"], ["student", "teacher_other", "admin"]) ]) @mark.parametrize("auth_test", authentication, indirect=True) @@ -144,256 +147,156 @@ def test_get_courses_name_ufora_id_teacher( assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] - # ### POST COURSES ### - # def test_post_courses_not_authenticated(self, client: FlaskClient): - # """Test posting a course when not authenticated""" - # response = client.post("/courses") - # assert response.status_code == 401 - - # def test_post_courses_bad_authentication_token(self, client: FlaskClient): - # """Test posting a course when given a bad authentication token""" - # response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_BAD}) - # assert response.status_code == 401 - - # def test_post_courses_no_authorization(self, client: FlaskClient): - # """Test posting a course when not having the correct authorization""" - # response = client.post("/courses", headers = {"Authorization": AUTH_TOKEN_STUDENT}) - # assert response.status_code == 403 - - # def test_post_courses_wrong_name_type(self, client: FlaskClient): - # """Test posting a course where the name does not have the correct type""" - # response = client.post( - # "/courses", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, - # json = { - # "name": 0, - # "ufora_id": "test" - # } - # ) - # assert response.status_code == 400 - - # def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): - # """Test posting a course where the ufora_id does not have the correct type""" - # response = client.post( - # "/courses", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, - # json = { - # "name": "test", - # "ufora_id": 0 - # } - # ) - # assert response.status_code == 400 - - # def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_entry): - # """Test posting a course where a field that doesn't occur in the model is given""" - # response = client.post( - # "/courses", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_1}, - # json = { - # "name": "test", - # "ufora_id": "test", - # "teacher": valid_teacher_entry.uid - # } - # ) - # assert response.status_code == 400 - - # def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): - # """Test posting a course""" - # response = client.post( - # "/courses", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - # json = { - # "name": "test", - # "ufora_id": "test" - # } - # ) - # assert response.status_code == 201 - # response = client.get( - # "/courses?name=test", - # headers = {"Authorization": AUTH_TOKEN_STUDENT} - # ) - # assert response.status_code == 200 - # data = response.json["data"] - # assert data[0]["ufora_id"] == "test" - # assert data[0]["teacher"] == valid_teacher_entry.uid # uid corresponds with AUTH_TOKEN - - # ### GET COURSE ### - # def test_get_course_not_authenticated(self, client: FlaskClient, valid_course_entry): - # """Test getting a course while not authenticated""" - # response = client.get(f"/courses/{valid_course_entry.course_id}") - # assert response.status_code == 401 - - # def test_get_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): - # """Test getting a course while using a bad authentication token""" - # response = client.get( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_BAD} - # ) - # assert response.status_code == 401 - - # def test_get_course_wrong_course_id(self, client: FlaskClient): - # """Test getting a non existing course by given a wrong course_id""" - # response = client.get( - # "/courses/0", - # headers = {"Authorization": AUTH_TOKEN_STUDENT} - # ) - # assert response.status_code == 404 - - # def test_get_course_correct(self, client: FlaskClient, valid_course_entry): - # """Test getting a course""" - # response = client.get( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_STUDENT} - # ) - # assert response.status_code == 200 - # data = response.json["data"] - # assert data["name"] == valid_course_entry.name - # assert data["ufora_id"] == valid_course_entry.ufora_id - # assert data["teacher"] == valid_course_entry.teacher - - # ### PATCH COURSE ### - # def test_patch_course_not_authenticated(self, client: FlaskClient, valid_course_entry): - # """Test patching a course while not authenticated""" - # response = client.patch(f"/courses/{valid_course_entry.course_id}") - # assert response.status_code == 401 - - # def test_patch_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): - # """Test patching a course while using a bad authentication token""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_BAD} - # ) - # assert response.status_code == 401 - - # def test_patch_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): - # """Test patching a course as a student""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_STUDENT} - # ) - # assert response.status_code == 403 - - # def test_patch_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): - # """Test patching a course as a teacher of a different course""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_1} - # ) - # assert response.status_code == 403 - - # def test_patch_course_wrong_course_id(self, client: FlaskClient, valid_course_entry): - # """Test patching a course that does not exist""" - # response = client.patch( - # "/courses/0", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - # ) - # assert response.status_code == 404 - - # def test_patch_course_wrong_name_type(self, client: FlaskClient, valid_course_entry): - # """Test patching a course given a wrong type for the course name""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - # json = {"name": 0} - # ) - # assert response.status_code == 400 - - # def test_patch_course_ufora_id_type(self, client: FlaskClient, valid_course_entry): - # """Test patching a course given a wrong type for the ufora_id""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - # json = {"ufora_id": 0} - # ) - # assert response.status_code == 400 - - # def test_patch_course_wrong_teacher_type(self, client: FlaskClient, valid_course_entry): - # """Test patching a course given a wrong type for the teacher""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - # json = {"teacher": 0} - # ) - # assert response.status_code == 400 - - # def test_patch_course_wrong_teacher(self, client: FlaskClient, valid_course_entry): - # """Test patching a course given a teacher that does not exist""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - # json = {"teacher": "no_teacher"} - # ) - # assert response.status_code == 400 - - # def test_patch_course_incorrect_field(self, client: FlaskClient, valid_course_entry): - # """Test patching a course with a field that doesn't occur in the course model""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - # json = {"field": 0} - # ) - # assert response.status_code == 400 - - # def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): - # """Test patching a course""" - # response = client.patch( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2}, - # json = {"name": "test"} - # ) - # assert response.status_code == 200 - # assert response.json["data"]["name"] == "test" - - # ### DELETE COURSE ### - # def test_delete_course_not_authenticated(self, client: FlaskClient, valid_course_entry): - # """Test deleting a course while not authenticated""" - # response = client.delete(f"/courses/{valid_course_entry.course_id}") - # assert response.status_code == 401 - - # def test_delete_course_bad_authentication_token(self, client: FlaskClient, valid_course_entry): - # """Test deleting a course while using a bad authentication token""" - # response = client.delete( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_BAD} - # ) - # assert response.status_code == 401 - - # def test_delete_course_no_authorization_student(self, client: FlaskClient, valid_course_entry): - # """Test deleting a course as a student""" - # response = client.delete( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_STUDENT} - # ) - # assert response.status_code == 403 - - # def test_delete_course_no_authorization_teacher(self, client: FlaskClient, valid_course_entry): - # """Test deleting a course as a teacher of a different course""" - # response = client.delete( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_1} - # ) - # assert response.status_code == 403 - - # def test_delete_course_wrong_course_id(self, client: FlaskClient): - # """Test deleting a course that does not exist""" - # response = client.delete( - # "/courses/0", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - # ) - # assert response.status_code == 404 - - # def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): - # """Test deleting a course""" - # response = client.delete( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - # ) - # assert response.status_code == 200 - # response = client.get( - # f"/courses/{valid_course_entry.course_id}", - # headers = {"Authorization": AUTH_TOKEN_TEACHER_2} - # ) - # assert response.status_code == 404 + ### POST COURSES ### + def test_post_courses_wrong_name_type(self, client: FlaskClient): + """Test posting a course where the name does not have the correct type""" + response = client.post("/courses", headers = {"Authorization": "teacher"}, + json = { + "name": 0, + "ufora_id": "test" + } + ) + assert response.status_code == 400 + + def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): + """Test posting a course where the ufora_id does not have the correct type""" + response = client.post("/courses", headers = {"Authorization": "teacher"}, + json = { + "name": "test", + "ufora_id": 0 + } + ) + assert response.status_code == 400 + + def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_entry): + """Test posting a course where a field that doesn't occur in the model is given""" + response = client.post("/courses", headers = {"Authorization": "teacher"}, + json = { + "name": "test", + "ufora_id": "test", + "teacher": valid_teacher_entry.uid + } + ) + assert response.status_code == 400 + + def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): + """Test posting a course""" + response = client.post("/courses", headers = {"Authorization": "teacher"}, + json = { + "name": "test", + "ufora_id": "test" + } + ) + assert response.status_code == 201 + response = client.get("/courses?name=test", headers = {"Authorization": "student"}) + assert response.status_code == 200 + data = response.json["data"] + assert data[0]["ufora_id"] == "test" + assert data[0]["teacher"] == valid_teacher_entry.uid + + ### GET COURSE ### + def test_get_course_wrong_course_id(self, client: FlaskClient): + """Test getting a non existing course by given a wrong course_id""" + response = client.get("/courses/0", headers = {"Authorization": "student"}) + assert response.status_code == 404 + + def test_get_course_correct(self, client: FlaskClient, valid_course_entry): + """Test getting a course""" + response = client.get( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "student"} + ) + assert response.status_code == 200 + data = response.json["data"] + assert data["name"] == valid_course_entry.name + assert data["ufora_id"] == valid_course_entry.ufora_id + assert data["teacher"] == valid_course_entry.teacher + + ### PATCH COURSE ### + def test_patch_course_wrong_course_id(self, client: FlaskClient, valid_course_entry): + """Test patching a course that does not exist""" + response = client.patch( + "/courses/0", + headers = {"Authorization": "teacher"} + ) + assert response.status_code == 404 + + def test_patch_course_wrong_name_type(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a wrong type for the course name""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "teacher"}, + json = {"name": 0} + ) + assert response.status_code == 400 + + def test_patch_course_ufora_id_type(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a wrong type for the ufora_id""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "teacher"}, + json = {"ufora_id": 0} + ) + assert response.status_code == 400 + + def test_patch_course_wrong_teacher_type(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a wrong type for the teacher""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "teacher"}, + json = {"teacher": 0} + ) + assert response.status_code == 400 + + def test_patch_course_wrong_teacher(self, client: FlaskClient, valid_course_entry): + """Test patching a course given a teacher that does not exist""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "teacher"}, + json = {"teacher": "no_teacher"} + ) + assert response.status_code == 400 + + def test_patch_course_incorrect_field(self, client: FlaskClient, valid_course_entry): + """Test patching a course with a field that doesn't occur in the course model""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "teacher"}, + json = {"field": 0} + ) + assert response.status_code == 400 + + def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): + """Test patching a course""" + response = client.patch( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "teacher"}, + json = {"name": "test"} + ) + assert response.status_code == 200 + assert response.json["data"]["name"] == "test" + + ### DELETE COURSE ### + def test_delete_course_wrong_course_id(self, client: FlaskClient): + """Test deleting a course that does not exist""" + response = client.delete( + "/courses/0", + headers = {"Authorization": "teacher"} + ) + assert response.status_code == 404 + + def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): + """Test deleting a course""" + response = client.delete( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "teacher"} + ) + assert response.status_code == 200 + response = client.get( + f"/courses/{valid_course_entry.course_id}", + headers = {"Authorization": "student"} + ) + assert response.status_code == 404 # ### GET COURSE ADMINS ### # ### POST COURSE ADMINS ### @@ -401,89 +304,3 @@ def test_get_courses_name_ufora_id_teacher( # ### GET COURSE STUDENTS ### # ### POST COURSE STUDENTS ### # ### DELETE COURSE STUDENTS ### - - # def test_post_courses(self, client, valid_course, invalid_course): - # """ - # Test posting a course to the /courses endpoint - # """ - - # response = client.post("/courses", json=valid_course, - # headers={"Authorization": "teacher2"}) - # assert response.status_code == 201 - # data = response.json - # assert data["data"]["name"] == "Sel" - # assert data["data"]["teacher"] == valid_course["teacher"] - - # # Is reachable using the API - # get_response = client.get(f"/courses/{data['data']['course_id']}", - # headers={"Authorization": "teacher2"}) - # assert get_response.status_code == 200 - - # response = client.post( - # "/courses?uid=Bart", json=invalid_course, - # headers={"Authorization": "teacher2"} - # ) # invalid course - # assert response.status_code == 400 - - # def test_post_no_name(self, client, course_empty_name): - # """ - # Test posting a course with a blank name - # """ - - # response = client.post("/courses?uid=Bart", json=course_empty_name, - # headers={"Authorization": "teacher2"}) - # assert response.status_code == 400 - - # def test_post_courses_course_id_students_and_admins( - # self, client, valid_course_entry, valid_students_entries): - # """ - # Test posting to courses/course_id/students and admins - # """ - - # # Posting to /courses/course_id/students and admins test - # sel2_students_link = "/courses/" + str(valid_course_entry.course_id) - - # valid_students = [s.uid for s in valid_students_entries] - - # response = client.post( - # sel2_students_link + f"/students?uid={valid_course_entry.teacher}", - # json={"students": valid_students}, headers={"Authorization": "teacher2"} - # ) - - # assert response.status_code == 403 - - # def test_get_courses(self, valid_course_entries, client): - # """ - # Test all the getters for the courses endpoint - # """ - - # response = client.get( - # "/courses", headers={"Authorization": "teacher1"}) - # assert response.status_code == 200 - # data = response.json - # for course in valid_course_entries: - # assert course.name in [c["name"] for c in data["data"]] - - # def test_course_delete(self, valid_course_entry, client): - # """Test all course endpoint related delete functionality""" - - # response = client.delete( - # "/courses/" + str(valid_course_entry.course_id), headers={"Authorization": "teacher2"} - # ) - # assert response.status_code == 200 - - # # Is not reachable using the API - # get_response = client.get(f"/courses/{valid_course_entry.course_id}", - # headers={"Authorization": "teacher2"}) - # assert get_response.status_code == 404 - - # def test_course_patch(self, valid_course_entry, client): - # """ - # Test the patching of a course - # """ - # response = client.patch(f"/courses/{valid_course_entry.course_id}", json={ - # "name": "TestTest" - # }, headers={"Authorization": "teacher2"}) - # data = response.json - # assert response.status_code == 200 - # assert data["data"]["name"] == "TestTest" From d6ebac818101a0501386a4f732f38958b688acfe Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 18:13:46 +0200 Subject: [PATCH 243/377] Adding all the other auth tests --- backend/tests/endpoints/conftest.py | 45 +++++++--------- .../tests/endpoints/course/courses_test.py | 51 ++++++++++++++++--- backend/tests/endpoints/endpoint.py | 5 +- backend/tests/endpoints/user_test.py | 6 +-- 4 files changed, 70 insertions(+), 37 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 62dae1a5..4d59defd 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -10,6 +10,7 @@ from flask.testing import FlaskClient from project.models.user import User,Role from project.models.course import Course +from project.models.course_relation import CourseStudent, CourseAdmin from project.models.course_share_code import CourseShareCode from project.models.submission import Submission, SubmissionStatus from project.models.project import Project @@ -32,11 +33,21 @@ def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry) return endpoint, getattr(client, method), *other ### USERS ### +@fixture +def valid_student_entry(session): + """Return a student entry""" + return session.get(User, "student") + @fixture def valid_teacher_entry(session): - """A valid teacher for testing that's already in the db""" + """Return a teacher entry""" return session.get(User, "teacher") +@fixture +def valid_admin_entry(session): + """Return an admin entry""" + return session.get(User, "admin") + ### COURSES ### @fixture def valid_course_entries(session, valid_teacher_entry): @@ -49,15 +60,17 @@ def valid_course_entries(session, valid_teacher_entry): @fixture def valid_course(valid_teacher_entry): """A valid course json form""" - return {"name": "SEL", "ufora_id": "C003784A_2023", "teacher": valid_teacher_entry.uid} + return Course(name="SEL", ufora_id="C003784A_2023", teacher=valid_teacher_entry.uid) @fixture -def valid_course_entry(session, valid_course): +def valid_course_entry(session, valid_course, valid_student_entry, valid_admin_entry): """A valid course for testing that's already in the db""" - course = Course(**valid_course) - session.add(course) + session.add(valid_course) + session.commit() + session.add(CourseStudent(course_id=valid_course.course_id, uid=valid_student_entry.uid)) + session.add(CourseAdmin(course_id=valid_course.course_id, uid=valid_admin_entry.uid)) session.commit() - return course + return valid_course @@ -108,26 +121,6 @@ def valid_user_entry(session, valid_user): session.commit() return user -@pytest.fixture -def valid_admin(): - """ - Returns a valid admin user form - """ - return { - "uid": "admin_person", - "role": Role.ADMIN, - } - -@pytest.fixture -def valid_admin_entry(session, valid_admin): - """ - Returns an admin user that is in the database - """ - user = User(**valid_admin) - session.add(user) - session.commit() - return user - @pytest.fixture def user_invalid_field(valid_user): """ diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index f2aa0d3f..0eee294c 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -14,7 +14,9 @@ class TestCourseEndpoint(TestEndpoint): # (endpoint, parameters, methods) authentication = authentication_tests([ ("/courses", [], ["get", "post"]), - ("/courses/@0", ["course_id"], ["get", "patch", "delete"]) + ("/courses/@0", ["course_id"], ["get", "patch", "delete"]), + ("/courses/@0/students", ["course_id"], ["get", "post", "delete"]), + ("/courses/@0/admins", ["course_id"], ["get", "post", "delete"]) ]) # Who can access what @@ -22,8 +24,25 @@ class TestCourseEndpoint(TestEndpoint): authorization = authorization_tests([ ("/courses", [], "get", ["student", "teacher", "admin"], []), ("/courses", [], "post", ["teacher"], ["student", "admin"]), - ("/courses/@0", ["course_id"], "patch", ["teacher"], ["student", "teacher_other", "admin"]), - ("/courses/@0", ["course_id"], "delete", ["teacher"], ["student", "teacher_other", "admin"]) + + ("/courses/@0", ["course_id"], "patch", + ["teacher"], ["student", "teacher_other", "admin"]), + ("/courses/@0", ["course_id"], "delete", + ["teacher"], ["student", "teacher_other", "admin"]), + + ("/courses/@0/students", ["course_id"], "get", + ["student", "teacher", "admin"], []), + ("/courses/@0/students", ["course_id"], "post", + ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), + ("/courses/@0/students", ["course_id"], "delete", + ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), + + ("/courses/@0/admins", ["course_id"], "get", + ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), + ("/courses/@0/admins", ["course_id"], "post", + ["teacher"], ["student", "teacher_other", "admin"]), + ("/courses/@0/admins", ["course_id"], "delete", + ["teacher"], ["student", "teacher_other", "admin"]), ]) @mark.parametrize("auth_test", authentication, indirect=True) @@ -147,6 +166,10 @@ def test_get_courses_name_ufora_id_teacher( assert response.status_code == 200 assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + + + + ### POST COURSES ### def test_post_courses_wrong_name_type(self, client: FlaskClient): """Test posting a course where the name does not have the correct type""" @@ -194,6 +217,10 @@ def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): assert data[0]["ufora_id"] == "test" assert data[0]["teacher"] == valid_teacher_entry.uid + + + + ### GET COURSE ### def test_get_course_wrong_course_id(self, client: FlaskClient): """Test getting a non existing course by given a wrong course_id""" @@ -212,6 +239,10 @@ def test_get_course_correct(self, client: FlaskClient, valid_course_entry): assert data["ufora_id"] == valid_course_entry.ufora_id assert data["teacher"] == valid_course_entry.teacher + + + + ### PATCH COURSE ### def test_patch_course_wrong_course_id(self, client: FlaskClient, valid_course_entry): """Test patching a course that does not exist""" @@ -276,6 +307,10 @@ def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): assert response.status_code == 200 assert response.json["data"]["name"] == "test" + + + + ### DELETE COURSE ### def test_delete_course_wrong_course_id(self, client: FlaskClient): """Test deleting a course that does not exist""" @@ -298,9 +333,13 @@ def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): ) assert response.status_code == 404 - # ### GET COURSE ADMINS ### - # ### POST COURSE ADMINS ### - # ### DELETE COURSE ADMINS ### + + + + # ### GET COURSE STUDENTS ### # ### POST COURSE STUDENTS ### # ### DELETE COURSE STUDENTS ### + # ### GET COURSE ADMINS ### + # ### POST COURSE ADMINS ### + # ### DELETE COURSE ADMINS ### diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index 1c468164..0b63d1bf 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -12,7 +12,7 @@ def authentication_tests(tests: List[Tuple[str, List[str], List[str]]]) -> List[ for method in methods: single_tests.append(param( (endpoint, parameters, method), - id = f"{endpoint} {method}" + id = f"{endpoint} {method.upper()}" )) return single_tests @@ -26,7 +26,8 @@ def authorization_tests(tests: List[Tuple[str, List[str], str, List[str], List[s allowed = token in allowed_tokens single_tests.append(param( (endpoint, parameters, method, token, allowed), - id = f"{endpoint} {method} {token} {'allowed' if allowed else 'disallowed'}" + id = f"{endpoint} {method.upper()} " \ + f"({token} {'allowed' if allowed else 'disallowed'})" )) return single_tests diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 7d3a0c39..b69f109b 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -146,14 +146,14 @@ def test_patch_user(self, client, valid_admin_entry, valid_user_entry): new_role = new_role.name response = client.patch(f"/users/{valid_user_entry.uid}", json={ 'role': new_role - }, headers={"Authorization":"admin1"}) + }, headers={"Authorization":"admin"}) assert response.status_code == 200 def test_patch_non_existent(self, client, valid_admin_entry): """Test updating a non-existent user.""" response = client.patch("/users/-20", json={ 'role': Role.TEACHER.name - }, headers={"Authorization":"admin1"}) + }, headers={"Authorization":"admin"}) assert response.status_code == 404 def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): @@ -165,7 +165,7 @@ def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): valid_user_form["role"] = Role.TEACHER.name response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form, - headers={"Authorization":"admin1"}) + headers={"Authorization":"admin"}) assert response.status_code == 415 def test_get_users_with_query(self, client, valid_user_entries): From 00b35e146a8d15f87edbf51400c60ed86a771a70 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 21:37:30 +0200 Subject: [PATCH 244/377] Tests for students --- backend/tests/conftest.py | 28 +- backend/tests/endpoints/conftest.py | 71 ++--- .../tests/endpoints/course/courses_test.py | 276 +++++++++++++----- .../tests/endpoints/course/share_link_test.py | 12 +- backend/tests/endpoints/user_test.py | 8 +- 5 files changed, 268 insertions(+), 127 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9e651330..628ff4f1 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,9 +2,15 @@ from datetime import datetime from zoneinfo import ZoneInfo +from os import getenv +from typing import Generator + from pytest import fixture +from flask import Flask +from sqlalchemy.orm import Session + from project import create_app_with_db -from project.sessionmaker import engine, Session +from project.sessionmaker import engine, Session as session_maker from project.db_in import db, url from project.models.course import Course from project.models.user import User,Role @@ -12,24 +18,26 @@ from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission, SubmissionStatus + + ### CLIENT & SESSION ### @fixture -def app(): +def app() -> Generator[Flask, any, None]: """Yield a Flask application instance with database""" app = create_app_with_db(url) yield app @fixture -def client(app): +def client(app) -> Generator[any, any, None]: """Yield a test client""" with app.test_client() as client: with app.app_context(): yield client @fixture -def session(): +def session() -> Generator[Session, any, None]: """Yield a database session for other fixtures to use""" - session = Session() + session = session_maker() try: # Create all tables db.metadata.create_all(engine) @@ -57,9 +65,11 @@ def session(): session.commit() session.close() + + ### AUTHENTICATION & AUTHORIZATION ### @fixture(autouse=True) # Always run this before a test -def auth_tokens(session): +def auth_tokens(session) -> None: """Add the authenticated users to the database""" session.add_all([ @@ -75,6 +85,12 @@ def auth_tokens(session): +### OTHER ### +@fixture +def api_host() -> str: + """Get the API URL from the environment""" + return getenv("API_HOST") or "" + ### OLD ### diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 4d59defd..c924c0dc 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,13 +1,15 @@ """Pytest fixtures""" import tempfile -import os from datetime import datetime from zoneinfo import ZoneInfo -from typing import Tuple +from typing import Tuple, List + import pytest from pytest import fixture, FixtureRequest from flask.testing import FlaskClient +from sqlalchemy.orm import Session + from project.models.user import User,Role from project.models.course import Course from project.models.course_relation import CourseStudent, CourseAdmin @@ -15,16 +17,18 @@ from project.models.submission import Submission, SubmissionStatus from project.models.project import Project + + ### AUTHENTICATEN & AUTHORIZATION ### @fixture -def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry) -> Tuple: +def auth_test(request: FixtureRequest, client: FlaskClient, course: Course) -> Tuple: """Add concrete test data""" # endpoint, parameters, method, token, status endpoint, parameters, method, *other = request.param d = { - "course_id": valid_course_entry.course_id + "course_id": course.course_id } for index, parameter in enumerate(parameters): @@ -32,47 +36,50 @@ def auth_test(request: FixtureRequest, client: FlaskClient, valid_course_entry) return endpoint, getattr(client, method), *other + + ### USERS ### @fixture -def valid_student_entry(session): +def student(session: Session) -> User: """Return a student entry""" return session.get(User, "student") @fixture -def valid_teacher_entry(session): +def student_other(session: Session) -> User: + """Return a student entry""" + return session.get(User, "student_other") + +@fixture +def teacher(session: Session) -> User: """Return a teacher entry""" return session.get(User, "teacher") @fixture -def valid_admin_entry(session): +def admin(session: Session) -> User: """Return an admin entry""" return session.get(User, "admin") + + ### COURSES ### @fixture -def valid_course_entries(session, valid_teacher_entry): - """A valid course for testing that's already in the db""" - courses = [Course(name=f"SEL{i}", teacher=valid_teacher_entry.uid) for i in range(1, 3)] +def courses(session: Session, teacher: User) -> List[Course]: + """Return course entries""" + courses = [Course(name=f"SEL{i}", teacher=teacher.uid) for i in range(1, 3)] session.add_all(courses) session.commit() return courses @fixture -def valid_course(valid_teacher_entry): - """A valid course json form""" - return Course(name="SEL", ufora_id="C003784A_2023", teacher=valid_teacher_entry.uid) - -@fixture -def valid_course_entry(session, valid_course, valid_student_entry, valid_admin_entry): - """A valid course for testing that's already in the db""" - session.add(valid_course) +def course(session: Session, student: User, teacher: User, admin: User) -> Course: + """Return a course entry""" + course = Course(name="SEL", ufora_id="C003784A_2023", teacher=teacher.uid) + session.add(course) session.commit() - session.add(CourseStudent(course_id=valid_course.course_id, uid=valid_student_entry.uid)) - session.add(CourseAdmin(course_id=valid_course.course_id, uid=valid_admin_entry.uid)) + session.add(CourseStudent(course_id=course.course_id, uid=student.uid)) + session.add(CourseAdmin(course_id=course.course_id, uid=admin.uid)) session.commit() - return valid_course - - + return course @@ -145,7 +152,6 @@ def valid_user_entries(session): return users - @pytest.fixture def file_empty(): """Return an empty file""" @@ -197,14 +203,14 @@ def valid_project_entry(session, valid_project): return project @pytest.fixture -def valid_project(valid_course_entry): +def valid_project(course): """A function that return the json form data of a project""" data = { "title": "Project", "description": "Test project", "assignment_file": "testfile", "deadline": "2024-02-25T12:00:00", - "course_id": valid_course_entry.course_id, + "course_id": course.course_id, "visible_for_students": True, "archived": False, "test_path": "tests", @@ -214,14 +220,9 @@ def valid_project(valid_course_entry): return data @pytest.fixture -def api_url(): - """Get the API URL from the environment.""" - return os.getenv("API_HOST") - -@pytest.fixture -def course_no_name(valid_teacher_entry): +def course_no_name(teacher): """A course with no name""" - return {"name": "", "teacher": valid_teacher_entry.uid} + return {"name": "", "teacher": teacher.uid} @pytest.fixture def course_empty_name(): @@ -245,9 +246,9 @@ def valid_students_entries(session): return students @pytest.fixture -def share_code_admin(session, valid_course_entry): +def share_code_admin(session, course): """A course with share codes for testing.""" - share_code = CourseShareCode(course_id=valid_course_entry.course_id, for_admins=True) + share_code = CourseShareCode(course_id=course.course_id, for_admins=True) session.add(share_code) session.commit() return share_code diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 0eee294c..8fb3e19c 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -4,6 +4,7 @@ from pytest import mark from flask.testing import FlaskClient from tests.endpoints.endpoint import TestEndpoint, authentication_tests, authorization_tests +from project.models.user import User from project.models.course import Course class TestCourseEndpoint(TestEndpoint): @@ -57,15 +58,13 @@ def test_authorization(self, auth_test: Tuple[str, any, str, bool]): - - ### GET COURSES ### - def test_get_courses_all(self, client: FlaskClient, valid_course_entries: List[Course]): + def test_get_courses_all(self, client: FlaskClient, courses: List[Course]): """Test getting all courses""" response = client.get("/courses", headers = {"Authorization": "student"}) assert response.status_code == 200 data = [course["name"] for course in response.json["data"]] - assert all(course.name in data for course in valid_course_entries) + assert all(course.name in data for course in courses) def test_get_courses_wrong_parameter(self, client: FlaskClient): """Test getting courses for a wrong parameter""" @@ -74,21 +73,18 @@ def test_get_courses_wrong_parameter(self, client: FlaskClient): def test_get_courses_wrong_name(self, client: FlaskClient): """Test getting courses for a wrong course name""" - response = client.get( - "/courses?name=no_name", - headers = {"Authorization": "student"} - ) + response = client.get("/courses?name=no_name", headers = {"Authorization": "student"}) assert response.status_code == 200 assert response.json["data"] == [] - def test_get_courses_name(self, client: FlaskClient, valid_course_entry: Course): + def test_get_courses_name(self, client: FlaskClient, course: Course): """Test getting courses for a given course name""" response = client.get( - f"/courses?name={valid_course_entry.name}", + f"/courses?name={course.name}", headers = {"Authorization": "student"} ) assert response.status_code == 200 - assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + assert response.json["data"][0]["name"] == course.name def test_get_courses_wrong_ufora_id(self, client: FlaskClient): """Test getting courses for a wrong ufora_id""" @@ -99,15 +95,14 @@ def test_get_courses_wrong_ufora_id(self, client: FlaskClient): assert response.status_code == 200 assert response.json["data"] == [] - def test_get_courses_ufora_id(self, client: FlaskClient, valid_course_entry: Course): + def test_get_courses_ufora_id(self, client: FlaskClient, course: Course): """Test getting courses for a given ufora_id""" response = client.get( - f"/courses?ufora_id={valid_course_entry.ufora_id}", + f"/courses?ufora_id={course.ufora_id}", headers = {"Authorization": "student"} ) assert response.status_code == 200 - assert valid_course_entry.ufora_id in \ - [course["ufora_id"] for course in response.json["data"]] + assert response.json["data"][0]["ufora_id"] == course.ufora_id def test_get_courses_wrong_teacher(self, client: FlaskClient): """Test getting courses for a wrong teacher""" @@ -118,55 +113,59 @@ def test_get_courses_wrong_teacher(self, client: FlaskClient): assert response.status_code == 200 assert response.json["data"] == [] - def test_get_courses_teacher(self, client: FlaskClient, valid_course_entry: Course): + def test_get_courses_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given teacher""" response = client.get( - f"/courses?teacher={valid_course_entry.teacher}", + f"/courses?teacher={course.teacher}", headers = {"Authorization": "student"} ) assert response.status_code == 200 - assert valid_course_entry.teacher in [course["teacher"] for course in response.json["data"]] + assert response.json["data"][0]["teacher"] == course.teacher - def test_get_courses_name_ufora_id(self, client: FlaskClient, valid_course_entry: Course): + def test_get_courses_name_ufora_id(self, client: FlaskClient, course: Course): """Test getting courses for a given course name and ufora_id""" response = client.get( - f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}", + f"/courses?name={course.name}&ufora_id={course.ufora_id}", headers = {"Authorization": "student"} ) assert response.status_code == 200 - assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + data = response.json["data"][0] + assert data["name"] == course.name + assert data["ufora_id"] == course.ufora_id - def test_get_courses_name_teacher(self, client: FlaskClient, valid_course_entry: Course): + def test_get_courses_name_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given course name and teacher""" response = client.get( - f"/courses?name={valid_course_entry.name}&teacher={valid_course_entry.teacher}", + f"/courses?name={course.name}&teacher={course.teacher}", headers = {"Authorization": "student"} ) assert response.status_code == 200 - assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + data = response.json["data"][0] + assert data["name"] == course.name + assert data["teacher"] == course.teacher - def test_get_courses_ufora_id_teacher(self, client: FlaskClient, valid_course_entry: Course): + def test_get_courses_ufora_id_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given ufora_id and teacher""" response = client.get( - f"/courses?ufora_id={valid_course_entry.ufora_id}&teacher={valid_course_entry.teacher}", + f"/courses?ufora_id={course.ufora_id}&teacher={course.teacher}", headers = {"Authorization": "student"} ) assert response.status_code == 200 - assert valid_course_entry.name in [course["name"] for course in response.json["data"]] + data = response.json["data"][0] + assert data["ufora_id"] == course.ufora_id + assert data["teacher"] == course.teacher - def test_get_courses_name_ufora_id_teacher( - self, client: FlaskClient, valid_course_entry: Course - ): + def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given name, ufora_id and teacher""" response = client.get( - f"/courses?name={valid_course_entry.name}&ufora_id={valid_course_entry.ufora_id}" \ - f"&teacher={valid_course_entry.teacher}", + f"/courses?name={course.name}&ufora_id={course.ufora_id}&teacher={course.teacher}", headers = {"Authorization": "student"} ) assert response.status_code == 200 - assert valid_course_entry.name in [course["name"] for course in response.json["data"]] - - + data = response.json["data"][0] + assert data["name"] == course.name + assert data["ufora_id"] == course.ufora_id + assert data["teacher"] == course.teacher @@ -191,18 +190,18 @@ def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): ) assert response.status_code == 400 - def test_post_courses_incorrect_field(self, client: FlaskClient, valid_teacher_entry): + def test_post_courses_incorrect_field(self, client: FlaskClient, teacher: User): """Test posting a course where a field that doesn't occur in the model is given""" response = client.post("/courses", headers = {"Authorization": "teacher"}, json = { "name": "test", "ufora_id": "test", - "teacher": valid_teacher_entry.uid + "teacher": teacher.uid } ) assert response.status_code == 400 - def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): + def test_post_courses_correct(self, client: FlaskClient, teacher: User): """Test posting a course""" response = client.post("/courses", headers = {"Authorization": "teacher"}, json = { @@ -213,94 +212,87 @@ def test_post_courses_correct(self, client: FlaskClient, valid_teacher_entry): assert response.status_code == 201 response = client.get("/courses?name=test", headers = {"Authorization": "student"}) assert response.status_code == 200 - data = response.json["data"] - assert data[0]["ufora_id"] == "test" - assert data[0]["teacher"] == valid_teacher_entry.uid - - + data = response.json["data"][0] + assert data["ufora_id"] == "test" + assert data["teacher"] == teacher.uid ### GET COURSE ### def test_get_course_wrong_course_id(self, client: FlaskClient): - """Test getting a non existing course by given a wrong course_id""" + """Test getting a non existing course by giving a wrong course_id""" response = client.get("/courses/0", headers = {"Authorization": "student"}) assert response.status_code == 404 - def test_get_course_correct(self, client: FlaskClient, valid_course_entry): + def test_get_course_correct(self, client: FlaskClient, course: Course): """Test getting a course""" response = client.get( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "student"} ) assert response.status_code == 200 data = response.json["data"] - assert data["name"] == valid_course_entry.name - assert data["ufora_id"] == valid_course_entry.ufora_id - assert data["teacher"] == valid_course_entry.teacher - - + assert data["name"] == course.name + assert data["ufora_id"] == course.ufora_id + assert data["teacher"] == course.teacher ### PATCH COURSE ### - def test_patch_course_wrong_course_id(self, client: FlaskClient, valid_course_entry): + def test_patch_course_wrong_course_id(self, client: FlaskClient): """Test patching a course that does not exist""" - response = client.patch( - "/courses/0", - headers = {"Authorization": "teacher"} - ) + response = client.patch("/courses/0", headers = {"Authorization": "teacher"}) assert response.status_code == 404 - def test_patch_course_wrong_name_type(self, client: FlaskClient, valid_course_entry): + def test_patch_course_wrong_name_type(self, client: FlaskClient, course: Course): """Test patching a course given a wrong type for the course name""" response = client.patch( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "teacher"}, json = {"name": 0} ) assert response.status_code == 400 - def test_patch_course_ufora_id_type(self, client: FlaskClient, valid_course_entry): + def test_patch_course_ufora_id_type(self, client: FlaskClient, course: Course): """Test patching a course given a wrong type for the ufora_id""" response = client.patch( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "teacher"}, json = {"ufora_id": 0} ) assert response.status_code == 400 - def test_patch_course_wrong_teacher_type(self, client: FlaskClient, valid_course_entry): + def test_patch_course_wrong_teacher_type(self, client: FlaskClient, course: Course): """Test patching a course given a wrong type for the teacher""" response = client.patch( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "teacher"}, json = {"teacher": 0} ) assert response.status_code == 400 - def test_patch_course_wrong_teacher(self, client: FlaskClient, valid_course_entry): + def test_patch_course_wrong_teacher(self, client: FlaskClient, course: Course): """Test patching a course given a teacher that does not exist""" response = client.patch( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "teacher"}, json = {"teacher": "no_teacher"} ) assert response.status_code == 400 - def test_patch_course_incorrect_field(self, client: FlaskClient, valid_course_entry): + def test_patch_course_incorrect_field(self, client: FlaskClient, course: Course): """Test patching a course with a field that doesn't occur in the course model""" response = client.patch( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "teacher"}, json = {"field": 0} ) assert response.status_code == 400 - def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): + def test_patch_course_correct(self, client: FlaskClient, course: Course): """Test patching a course""" response = client.patch( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "teacher"}, json = {"name": "test"} ) @@ -309,8 +301,6 @@ def test_patch_course_correct(self, client: FlaskClient, valid_course_entry): - - ### DELETE COURSE ### def test_delete_course_wrong_course_id(self, client: FlaskClient): """Test deleting a course that does not exist""" @@ -320,26 +310,160 @@ def test_delete_course_wrong_course_id(self, client: FlaskClient): ) assert response.status_code == 404 - def test_delete_course_correct(self, client: FlaskClient, valid_course_entry): + def test_delete_course_correct(self, client: FlaskClient, course: Course): """Test deleting a course""" response = client.delete( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "teacher"} ) assert response.status_code == 200 response = client.get( - f"/courses/{valid_course_entry.course_id}", + f"/courses/{course.course_id}", headers = {"Authorization": "student"} ) assert response.status_code == 404 + ### GET COURSE STUDENTS ### + def test_get_students_wrong_course_id(self, client: FlaskClient): + """Test getting the students of a non existing course by giving a wrong course_id""" + response = client.get("/courses/0/students", headers = {"Authorization": "student"}) + assert response.status_code == 404 + + def test_get_students_correct(self, client: FlaskClient, api_host: str, course: Course): + """Test getting the students fo a course""" + response = client.get( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "student"} + ) + assert response.status_code == 200 + assert response.json["data"][0]["uid"] == f"{api_host}/users/student" + + + + ### POST COURSE STUDENTS ### + def test_post_students_wrong_course_id(self, client: FlaskClient): + """Test adding students to a non existing course""" + response = client.post("/courses/0/students", headers = {"Authorization": "teacher"}) + assert response.status_code == 404 + + def test_post_students_wrong_students_type( + self, client: FlaskClient, course: Course, student_other: User + ): + """Test adding a student without putting it in a list""" + response = client.post( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "students": student_other.uid + } + ) + assert response.status_code == 400 + + def test_post_students_wrong_students(self, client: FlaskClient, course: Course): + """Test adding students with invalid uid values in the list""" + response = client.post( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "students": [None, "no_user"] + } + ) + assert response.status_code == 400 + def test_post_students_incorrect_field( + self, client: FlaskClient, course: Course, student_other: User + ): + """Test adding students but give unnecessary fields to the data""" + response = client.post( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "incorrect": [student_other.uid] + } + ) + assert response.status_code == 400 + + def test_post_students_correct( + self, client: FlaskClient, course: Course, student_other: User + ): + """Test adding students to a course""" + response = client.post( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "students": [student_other.uid] + } + ) + assert response.status_code == 201 + data = response.json["data"][0] + assert not data + + + + ### DELETE COURSE STUDENTS ### + def test_delete_students_wrong_course_id(self, client: FlaskClient): + """Test deleting students from a non existing course""" + response = client.delete("/courses/0/students", headers = {"Authorization": "teacher"}) + assert response.status_code == 404 + + def test_delete_students_wrong_students_type( + self, client: FlaskClient, course: Course, student_other: User + ): + """Test deleting a student without putting it in a list""" + response = client.delete( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "students": student_other.uid + } + ) + assert response.status_code == 400 + + def test_delete_students_wrong_students(self, client: FlaskClient, course: Course): + """Test deleting students with invalid uid values in the list""" + response = client.delete( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "students": [None, "no_user"] + } + ) + assert response.status_code == 400 + + def test_delete_students_incorrect_field( + self, client: FlaskClient, course: Course, student: User + ): + """Test deleting students with an extra field that should not be there""" + response = client.delete( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "incorrect": [student.uid] + } + ) + assert response.status_code == 400 + + def test_delete_students_correct( + self, client: FlaskClient, course: Course, student: User + ): + """Test deleting students from a course""" + response = client.delete( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "teacher"}, + json = { + "students": [student.uid] + } + ) + assert response.status_code == 200 + response = client.get( + f"/courses/{course.course_id}/students", + headers = {"Authorization": "student"} + ) + assert response.status_code == 200 + assert response.json["data"] == [] - # ### GET COURSE STUDENTS ### - # ### POST COURSE STUDENTS ### - # ### DELETE COURSE STUDENTS ### # ### GET COURSE ADMINS ### # ### POST COURSE ADMINS ### # ### DELETE COURSE ADMINS ### diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index dc071b15..2575cb3e 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -9,16 +9,16 @@ class TestCourseShareLinks: and everyone should be able to list all students assigned to a course """ - def test_get_share_links(self, client, valid_course_entry): + def test_get_share_links(self, client, course): """Test whether the share links are accessible""" - response = client.get(f"courses/{valid_course_entry.course_id}/join_codes", + response = client.get(f"courses/{course.course_id}/join_codes", headers={"Authorization":"teacher"}) assert response.status_code == 200 - def test_post_share_links(self, client, valid_course_entry): + def test_post_share_links(self, client, course): """Test whether the share links are accessible to post to""" response = client.post( - f"courses/{valid_course_entry.course_id}/join_codes", + f"courses/{course.course_id}/join_codes", json={"for_admins": True}, headers={"Authorization":"teacher"}) assert response.status_code == 201 @@ -41,9 +41,9 @@ def test_post_share_links_404(self, client): headers={"Authorization":"teacher2"}) assert response.status_code == 404 - def test_for_admins_required(self, client, valid_course_entry): + def test_for_admins_required(self, client, course): """Test whether the for_admins field is required""" - response = client.post(f"courses/{valid_course_entry.course_id}/join_codes", + response = client.post(f"courses/{course.course_id}/join_codes", json={}, headers={"Authorization":"teacher"}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index b69f109b..f5423e89 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -119,7 +119,7 @@ def test_get_one_user_wrong_authentication(self, client, valid_user_entry): response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"wrong"}) assert response.status_code == 401 - def test_patch_user_not_authorized(self, client, valid_admin_entry, valid_user_entry): + def test_patch_user_not_authorized(self, client, admin, valid_user_entry): """Test updating a user.""" if valid_user_entry.role == Role.TEACHER: @@ -134,7 +134,7 @@ def test_patch_user_not_authorized(self, client, valid_admin_entry, valid_user_e }, headers={"Authorization":"student01"}) assert response.status_code == 403 # Patching a user is not allowed as a not-admin - def test_patch_user(self, client, valid_admin_entry, valid_user_entry): + def test_patch_user(self, client, admin, valid_user_entry): """Test updating a user.""" if valid_user_entry.role == Role.TEACHER: @@ -149,14 +149,14 @@ def test_patch_user(self, client, valid_admin_entry, valid_user_entry): }, headers={"Authorization":"admin"}) assert response.status_code == 200 - def test_patch_non_existent(self, client, valid_admin_entry): + def test_patch_non_existent(self, client, admin): """Test updating a non-existent user.""" response = client.patch("/users/-20", json={ 'role': Role.TEACHER.name }, headers={"Authorization":"admin"}) assert response.status_code == 404 - def test_patch_non_json(self, client, valid_admin_entry, valid_user_entry): + def test_patch_non_json(self, client, admin, valid_user_entry): """Test sending a non-JSON patch request.""" valid_user_form = asdict(valid_user_entry) if valid_user_form["role"] == Role.TEACHER.name: From bca882436f4b5612c41c5b4b49ef01061fd5b435 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:17:40 +0200 Subject: [PATCH 245/377] Admin tests --- .../tests/endpoints/course/courses_test.py | 130 +++++++++++++++++- 1 file changed, 127 insertions(+), 3 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 8fb3e19c..55f72d9b 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -464,6 +464,130 @@ def test_delete_students_correct( assert response.status_code == 200 assert response.json["data"] == [] - # ### GET COURSE ADMINS ### - # ### POST COURSE ADMINS ### - # ### DELETE COURSE ADMINS ### + + + ### GET COURSE ADMINS ### + def test_get_admins_wrong_course_id(self, client: FlaskClient): + """Test getting the admins of a non existing course""" + response = client.get("/courses/0/admins", headers = {"Authorization": "teacher"}) + assert response.status_code == 404 + + def test_get_admins_correct(self, client: FlaskClient, api_host: str, course: Course): + """Test getting the admins of a course""" + response = client.get( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"} + ) + assert response.status_code == 200 + assert response.json["data"][0]["uid"] == f"{api_host}/users/admin" + + + + ### POST COURSE ADMINS ### + def test_post_admins_wrong_course_id(self, client: FlaskClient): + """Test adding admins to a non existing course""" + response = client.post("/courses/0/admins", headers = {"Authorization": "teacher"}) + assert response.status_code == 404 + + def test_post_admins_wrong_admin_uid_type(self, client: FlaskClient, course: Course): + """Test adding an admin where the uid has a wrong typing""" + response = client.post( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "admin_uid": None + } + ) + assert response.status_code == 400 + + def test_post_admins_wrong_user(self, client: FlaskClient, course: Course, student: User): + """Test adding a student as an admin""" + response = client.post( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "admin_uid": student.uid + } + ) + assert response.status_code == 400 + + def test_post_admins_incorrect_field(self, client: FlaskClient, course: Course, admin: User): + """Test adding an admin but the data has an incorrect field""" + response = client.post( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "incorrect": admin.uid + } + ) + assert response.status_code == 400 + + def test_post_admins_correct(self, client: FlaskClient, course: Course, admin: User): + """Test adding an admin to a course""" + response = client.post( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "admin_uid": admin.uid + } + ) + assert response.status_code == 201 + assert response.json["data"]["uid"] == admin.uid + + + + ### DELETE COURSE ADMINS ### + def test_delete_admins_wrong_course_id(self, client: FlaskClient): + """Test deleting an admin from a non existing course""" + response = client.delete("/courses/0/admins", headers = {"Authorization": "teacher"}) + assert response.status_code == 404 + + def test_delete_admins_wrong_admin_uid_type(self, client: FlaskClient, course: Course): + """Test deleting an admin where the uid has the wrong typing""" + response = client.delete( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "admin_uid": None + } + ) + assert response.status_code == 400 + + def test_delete_admins_wrong_user(self, client: FlaskClient, course: Course, student: User): + """Test deleting an user that is not an admin for this course""" + response = client.delete( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "admin_uid": student.uid + } + ) + assert response.status_code == 400 + + def test_delete_admins_incorrect_field(self, client: FlaskClient, course: Course, admin: User): + """Test deleting an admin but the data has an incorrect field""" + response = client.delete( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "incorrect": admin.uid + } + ) + assert response.status_code == 400 + + def test_delete_admins_correct(self, client: FlaskClient, course: Course, admin: User): + """Test deleting an admin from a course""" + response = client.delete( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"}, + json = { + "admin_uid": admin.uid + } + ) + assert response.status_code == 200 + response = client.get( + f"/courses/{course.course_id}/admins", + headers = {"Authorization": "teacher"} + ) + assert response.status_code == 200 + assert response.json["data"] == [] From 764738181feecb13af60fa9561a845e181e06d0e Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:39:03 +0200 Subject: [PATCH 246/377] Forgot part of merge --- frontend/package-lock.json | 4 ---- frontend/package.json | 4 ---- frontend/public/locales/en/translation.json | 9 --------- frontend/public/locales/nl/translation.json | 9 --------- frontend/src/components/Header/Header.tsx | 11 ----------- frontend/src/i18n.js | 7 ------- frontend/src/pages/home/Home.tsx | 4 ---- 7 files changed, 48 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5b55e64c..b55bc096 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,11 +36,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", "i18next": "^23.10.1", -<<<<<<< HEAD - "i18next-browser-languagedetector": "^7.2.0", -======= "i18next-browser-languagedetector": "^7.2.1", ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", "typescript": "^5.2.2", diff --git a/frontend/package.json b/frontend/package.json index 621b409e..825d4653 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,11 +40,7 @@ "eslint-plugin-react-refresh": "^0.4.5", "eslint-plugin-tsdoc": "^0.2.17", "i18next": "^23.10.1", -<<<<<<< HEAD - "i18next-browser-languagedetector": "^7.2.0", -======= "i18next-browser-languagedetector": "^7.2.1", ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", "typescript": "^5.2.2", diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 6101fea9..c903be2f 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1,12 +1,4 @@ { -<<<<<<< HEAD - "homepage": "Homepage", - "myProjects": "My Projects", - "myCourses": "My Courses", - "login": "Login", - "home": "Home" - } -======= "header": { "myProjects": "My Projects", "myCourses": "My Courses", @@ -21,4 +13,3 @@ "emptyCourseNameError": "Course name should not be empty" } } ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 86918b57..cc5fd269 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -1,12 +1,4 @@ { -<<<<<<< HEAD - "homepage": "Homepage", - "myProjects": "Mijn Projecten", - "myCourses": "Mijn Vakken", - "login": "Login", - "home": "Home" - } -======= "header": { "myProjects": "Mijn Projecten", "myCourses": "Mijn Vakken", @@ -21,4 +13,3 @@ "emptyCourseNameError": "Vak naam mag niet leeg zijn" } } ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index dcc2c9b7..f7cdb63f 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -15,21 +15,15 @@ import { } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; import { useTranslation } from "react-i18next"; -<<<<<<< HEAD -======= import { useEffect, useState } from "react"; import LanguageIcon from "@mui/icons-material/Language"; import { Link, useLocation } from "react-router-dom"; ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 /** * The header component for the application that will be rendered at the top of the page. * @returns - The header component */ export function Header(): JSX.Element { -<<<<<<< HEAD - const { t } = useTranslation(); -======= const { t, i18n } = useTranslation('translation', { keyPrefix: 'header' }); const [languageMenuAnchor, setLanguageMenuAnchor] = useState(null); @@ -69,7 +63,6 @@ export function Header(): JSX.Element { const title = getTitle(location.pathname, t); ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 return ( @@ -78,11 +71,7 @@ export function Header(): JSX.Element { -<<<<<<< HEAD - {t('home')} -======= {title} ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9
diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index 780a8887..0c5090bb 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -3,14 +3,11 @@ import { initReactI18next } from 'react-i18next'; import Backend from 'i18next-http-backend'; import LanguageDetector from 'i18next-browser-languagedetector'; -<<<<<<< HEAD -======= const detectionOptions = { order: ['path', 'navigator', 'localStorage', 'subdomain', 'queryString', 'htmlTag'], lookupFromPathIndex: 0 } ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 i18n .use(Backend) .use(LanguageDetector) @@ -18,11 +15,7 @@ i18n .init({ fallbackLng: 'en', debug: true, -<<<<<<< HEAD - -======= detection: detectionOptions, ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 interpolation: { escapeValue: false, } diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 32a54b29..f1661e80 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -8,11 +8,7 @@ export default function Home() { const { t } = useTranslation(); return (
-<<<<<<< HEAD -

{t('homepage')}

-=======

->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9
); } From d2cf519e72f172e4c1ed316d680f774c6b885b4f Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:46:03 +0200 Subject: [PATCH 247/377] Fix #179 (#180) * Fix #179 * adjusted test to new endpoint * fixed test * fixed linting --- .../projects/project_assignment_file.py | 26 +++++++------------ .../endpoints/projects/project_endpoint.py | 4 +-- backend/tests/endpoints/project_test.py | 6 ++--- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 88e12ac7..01bf59ee 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -6,18 +6,17 @@ from urllib.parse import urljoin from flask import send_from_directory -from werkzeug.utils import safe_join from flask_restful import Resource -from project.models.project import Project -from project.utils.query_agent import query_by_id_from_model from project.utils.authentication import authorize_project_visible API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") UPLOAD_FOLDER = os.getenv('UPLOAD_URL') +ASSIGNMENT_FILE_NAME = "assignment.md" + class ProjectAssignmentFiles(Resource): """ Class for getting the assignment files of a project @@ -28,24 +27,17 @@ def get(self, project_id): """ Get the assignment files of a project """ - json, status_code = query_by_id_from_model( - Project, - "project_id", - project_id, - "RESPONSE_URL" - ) - - if status_code != 200: - return json, status_code - project = json["data"] - file_url = safe_join(UPLOAD_FOLDER, str(project_id)) + directory_path = os.path.abspath(os.path.join(UPLOAD_FOLDER, str(project_id))) + assignment_file = os.path.join(directory_path, ASSIGNMENT_FILE_NAME) - if not os.path.isfile(safe_join(file_url, project.assignment_file)): + if not os.path.isfile(assignment_file): # no file is found so return 404 return { "message": "No assignment file found for this project", - "url": file_url + "url": f"{API_URL}/projects/{project_id}/assignment" }, 404 - return send_from_directory(file_url, project.assignment_file) + + + return send_from_directory(directory_path, ASSIGNMENT_FILE_NAME) diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index 0c4eee20..a48b615d 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -22,6 +22,6 @@ ) project_bp.add_url_rule( - '/projects//assignments', - view_func=ProjectAssignmentFiles.as_view('project_assignments') + '/projects//assignment', + view_func=ProjectAssignmentFiles.as_view('project_assignment') ) diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 2cda69b6..46f7bcbc 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -16,10 +16,10 @@ def test_assignment_download(client, valid_project): ) assert response.status_code == 201 project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignments", + response = client.get(f"/projects/{project_id}/assignment", headers={"Authorization":"teacher2"}) - # file downloaded succesfully - assert response.status_code == 200 + # 404 because the file is not found, no assignment.md in zip file + assert response.status_code == 404 def test_not_found_download(client): From 433f421e0a7eebc8f357a2414a7402e19e342907 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:48:44 +0200 Subject: [PATCH 248/377] Whoops --- backend/project/static/OpenAPI_Object.json | 2272 ------------------- frontend/package-lock.json | 11 +- frontend/public/locales/en/translation.json | 2 +- frontend/public/locales/nl/translation.json | 2 +- frontend/src/pages/home/Home.tsx | 4 +- 5 files changed, 4 insertions(+), 2287 deletions(-) delete mode 100644 backend/project/static/OpenAPI_Object.json diff --git a/backend/project/static/OpenAPI_Object.json b/backend/project/static/OpenAPI_Object.json deleted file mode 100644 index ba0b5381..00000000 --- a/backend/project/static/OpenAPI_Object.json +++ /dev/null @@ -1,2272 +0,0 @@ -{ - "openapi": "3.0.1", - "info": { - "title": "Pigeonhole API", - "summary": "A project submission and grading API for University Ghent students and professors.", - "description": "The API built for the Pigeonhole application. It serves as an interface for student of University Ghent. They can submit solutions to projects created by their professors. Professors and their assistents can then review these submitions, grade them and define custom tests that automatically run on every submition. The API is built using the OpenAPI 3.1.0 specification.", - "version": "1.0.0", - "contact": { - "name": "Project discussion forum", - "url": "https://github.com/SELab-2/UGent-opgave/discussions", - "email": "Bart.Coppens@UGent.be" - }, - "x-authors": [ - { - "name": "Aron Buzogany", - "github": "https://github.com/AronBuzogany" - }, - { - "name": "Gerwoud Van den Eynden", - "github": "https://github.com/Gerwoud" - }, - { - "name": "Jarne Clauw", - "github": "https://github.com/JarneClauw" - }, - { - "name": "Siebe Vlietinck", - "github": "https://github.com/Vucis" - }, - { - "name": "Warre Provoost", - "github": "https://github.com/warreprovoost" - }, - { - "name": "Cedric Mekeirle", - "github": "https://github.com/JibrilExe" - }, - { - "name": "Matisse Sulzer", - "github": "https://github.com/Matisse-Sulzer" - } - ] - }, - "paths": { - "/projects": { - "get": { - "description": "Returns all projects from the database that the user has access to", - "responses": { - "200": { - "description": "A list of projects", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "project_id": { - "type": "integer" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "post": { - "description": "Upload a new project", - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "assignment_file": { - "type": "string", - "format": "binary" - }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "course_id": { "type": "integer" }, - "visible_for_students": { "type": "boolean" }, - "archived": { "type": "boolean" } - }, - "required": ["assignment_file", "title", "description", "course_id", "visible_for_students", "archived"] - } - } - } - }, - "responses": { - "201": { - "description": "Uploaded a new project succesfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Bad formatted request for uploading a project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Something went wrong inserting model into the database", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error":{ - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/projects/{id}": { - "get": { - "description": "Return a project with corresponding id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the project to retrieve", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "A project with corresponding id", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "project_id": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "assignment_file": { - "type": "string", - "format": "binary" - }, - "deadline": { - "type": "string" - }, - "course_id": { - "type": "integer" - }, - "visible_for_students": { - "type": "boolean" - }, - "archived": { - "type": "boolean" - }, - "test_path": { - "type": "string" - }, - "script_name": { - "type": "string" - }, - "regex_expressions": { - "type": "array" - } - } - } - } - } - }, - "404": { - "description": "An id that doesn't correspond to an existing project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object" - }, - "message": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Something in the database went wrong fetching the project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - } - } - }, - "patch": { - "description": "Patch certain fields of a project", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the project to retrieve", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "properties": { - "assignment_file": { - "type": "string", - "format": "binary" - }, - "title": { "type": "string" }, - "description": { "type": "string" }, - "course_id": { "type": "integer" }, - "visible_for_students": { "type": "boolean" }, - "archived": { "type": "boolean" } - } - } - } - } - }, - "responses": { - "200": { - "description": "Patched a project succesfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object" - }, - "message": { - "type": "string" - }, - "url": { "type": "string" } - } - } - } - } - }, - "404": { - "description": "Tried to patch a project that is not present", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Something went wrong in the database trying to patch the project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Delete a project with given id", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "ID of the project to retrieve", - "required": true, - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Removed a project succesfully", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" }, - "url": { "type": "string" } - } - } - } - } - }, - "404": { - "description": "Tried to remove a project that is not present", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { "type": "string" }, - "url": { "type": "string" } - } - } - } - } - }, - "500": { - "description": "Something went wrong in the database trying to remove the project", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { "type": "string" }, - "url": { "type": "string" } - } - } - } - } - } - } - } - }, - "/courses": { - "get": { - "description": "Get a list of all courses.", - "responses": { - "200": { - "description": "Successfully retrieved all courses with given parameters", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "array", - "items": { - "type": "object", - "properties": { - "course_id": { - "type": "integer" - }, - "name": { - "type": "string" - }, - "ufora_id": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "url": { - "type": "string" - } - } - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - }, - "parameters": [ - { - "name": "name", - "in": "query", - "description": "Name of the course", - "schema": { - "type": "string" - } - }, - { - "name": "ufora_id", - "in": "query", - "description": "Ufora ID of the course", - "schema": { - "type": "string" - } - }, - { - "name": "teacher", - "in": "query", - "description": "Teacher of the course", - "schema": { - "type": "string" - } - } - ] - }, - "post": { - "description": "Create a new course.", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the course" - }, - "teacher": { - "type": "string", - "description": "Teacher of the course" - } - }, - "required": [ - "name", - "teacher" - ] - } - } - } - }, - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Course with name: {name} and course_id: {course_id} was successfully created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "course_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "ufora_id": { - "type": "string" - } - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to create a course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "The user trying to create a course was not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/courses/{course_id}": { - "get": { - "description": "Get a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Course found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "ufora_id": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "admins": { - "type": "array", - "items": { - "type": "string" - } - }, - "students": { - "type": "array", - "items": { - "type": "string" - } - }, - "projects": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Delete a course by its ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Course deleted.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "Successfully deleted course with course_id: {course_id}" - ] - }, - "url": { - "type": "string", - "examples": [ - "{API_URL}/courses" - ] - } - } - } - } - } - }, - "403": { - "description": "The user trying to delete the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "patch": { - "description": "Update the course with given ID.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name of the course" - }, - "teacher": { - "type": "string", - "description": "Teacher of the course" - }, - "ufora_id": { - "type": "string", - "description": "Ufora ID of the course" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Course updated.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "url": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "course_id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "teacher": { - "type": "string" - }, - "ufora_id": { - "type": "string" - } - } - } - } - } - } - } - }, - "403": { - "description": "The user trying to update the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/courses/{course_id}/students": { - "get": { - "description": "Get a list of all students in a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved all students of course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "post": { - "description": "Assign students to a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "students", - "in": "body", - "description": "list of uids of the students to be assigned to the course", - "required": true, - "schema": { - "type": "array" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "Students assigned to course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "User were succesfully added to the course" - ] - }, - "url": { - "type": "string", - "examples": [ - "http://api.example.com/courses/123/students" - ] - }, - "data": { - "type": "object", - "properties": { - "students": { - "type": "array", - "items": { - "type": "string", - "examples": [ - "http://api.example.com/users/123" - ] - } - } - } - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to assign students to the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Remove students from a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "students", - "in": "body", - "description": "list of uids of the students to be removed from the course", - "required": true, - "schema": { - "type": "array" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Students removed from course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": "User were succesfully removed from the course" - }, - "url": { - "type": "string", - "examples": [ - "API_URL + /courses/ + str(course_id) + /students" - ] - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to remove students from the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/courses/{course_id}/admins": { - "get": { - "description": "Get a list of all admins in a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved all admins of course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "type": "string" - } - }, - "url": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "post": { - "description": "Assign admins to a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "admin_uid", - "in": "body", - "description": "uid of the admin to be assigned to the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "201": { - "description": "User were successfully added to the course.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "User were successfully added to the course." - ] - }, - "url": { - "type": "string", - "examples": [ - "http://api.example.com/courses/123/students" - ] - }, - "data": { - "type": "object", - "properties": { - "students": { - "type": "array", - "items": { - "type": "string" - }, - "examples": [ - "http://api.example.com/users/1", - "http://api.example.com/users/2" - ] - } - } - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to assign admins to the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "description": "Remove an admin from a course.", - "parameters": [ - { - "name": "course_id", - "in": "path", - "description": "ID of the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "admin_uid", - "in": "body", - "description": "uid of the admin to be removed from the course", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "uid", - "in": "query", - "description": "uid of the user sending the request", - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "User was successfully removed from the course admins.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "examples": [ - "User was successfully removed from the course admins." - ] - }, - "url": { - "type": "string", - "examples": [ - "API_URL + /courses/ + str(course_id) + /students" - ] - } - } - } - } - } - }, - "400": { - "description": "There was no uid in the request query.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "403": { - "description": "The user trying to remove the admin from the course was unauthorized.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "Course not found.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "Internal server error.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/users": { - "get": { - "summary": "Get all users", - "responses": { - "200": { - "description": "A list of users", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "uid", - "is_teacher", - "is_admin" - ] - } - } - } - } - } - } - }, - "post": { - "summary": "Create a new user", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "uid", - "is_teacher", - "is_admin" - ] - } - } - } - }, - "responses": { - "201": { - "description": "User created successfully" - }, - "400": { - "description": "Invalid request data" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while creating the user" - } - } - }, - "/users/{user_id}": { - "get": { - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "A user", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string" - }, - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "uid", - "is_teacher", - "is_admin" - ] - } - } - } - }, - "404": { - "description": "User not found" - } - } - }, - "patch": { - "summary": "Update a user's information", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "is_teacher": { - "type": "boolean" - }, - "is_admin": { - "type": "boolean" - } - }, - "required": [ - "is_teacher", - "is_admin" - ] - } - } - } - }, - "responses": { - "200": { - "description": "User updated successfully" - }, - "404": { - "description": "User not found" - }, - "415": { - "description": "Unsupported Media Type. Expected JSON." - }, - "500": { - "description": "An error occurred while patching the user" - } - } - }, - "delete": { - "summary": "Delete a user", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User deleted successfully" - }, - "404": { - "description": "User not found" - }, - "500": { - "description": "An error occurred while deleting the user" - } - } - } - } - } - }, - "/submissions": { - "get": { - "summary": "Gets the submissions", - "parameters": [ - { - "name": "uid", - "in": "query", - "description": "User ID", - "schema": { - "type": "string" - } - }, - { - "name": "project_id", - "in": "query", - "description": "Project ID", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Successfully retrieved a list of submission URLs", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submissions": "array", - "items": { - "type": "string", - "format": "uri" - } - } - } - } - } - } - } - }, - "400": { - "description": "An invalid user or project is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "post": { - "summary": "Posts a new submission to a project", - "requestBody": { - "description": "Form data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "uid": { - "type": "string", - "required": true - }, - "project_id": { - "type": "integer", - "required": true - }, - "files": { - "type": "array", - "items": { - "type": "file" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Successfully posts the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } - } - } - } - } - } - } - }, - "400": { - "description": "An invalid user, project or list of files is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/submissions/{submission_id}": { - "get": { - "summary": "Gets the submission", - "responses": { - "200": { - "description": "Successfully retrieved the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "submission": { - "type": "object", - "properties": { - "submission_id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer", - "nullable": true - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } - } - } - } - } - } - } - } - } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "patch": { - "summary": "Patches the submission", - "requestBody": { - "description": "The submission data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "grading": { - "type": "integer", - "minimum": 0, - "maximum": 20 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Successfully patches the submission and retrieves its data", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "user": { - "type": "string", - "format": "uri" - }, - "project": { - "type": "string", - "format": "uri" - }, - "grading": { - "type": "integer" - }, - "time": { - "type": "string", - "format": "date-time" - }, - "path": { - "type": "string" - }, - "status": { - "type": "boolean" - } - } - } - } - } - } - } - }, - "400": { - "description": "An invalid submission grading is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "delete": { - "summary": "Deletes the submission", - "responses": { - "200": { - "description": "Successfully deletes the submission", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "404": { - "description": "An invalid submission id is given", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "500": { - "description": "An internal server error occurred", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri" - }, - "message": { - "type": "string" - } - } - } - } - } - } - } - }, - "parameters": [ - { - "name": "submission_id", - "in": "path", - "description": "Submission ID", - "required": true, - "schema": { - "type": "integer" - } - } - ] - } -} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b55bc096..da948817 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4096,15 +4096,9 @@ } }, "node_modules/i18next-browser-languagedetector": { -<<<<<<< HEAD - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.0.tgz", - "integrity": "sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==", -======= "version": "7.2.1", "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.1.tgz", "integrity": "sha512-h/pM34bcH6tbz8WgGXcmWauNpQupCGr25XPp9cZwZInR9XHSjIFDYp1SIok7zSPsTOMxdvuLyu86V+g2Kycnfw==", ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 "dev": true, "dependencies": { "@babel/runtime": "^7.23.2" @@ -6315,8 +6309,6 @@ } } }, -<<<<<<< HEAD -======= "node_modules/vite/node_modules/postcss": { "version": "8.4.38", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", @@ -6345,7 +6337,6 @@ "node": "^10 || ^12 || >=14" } }, ->>>>>>> 496cd4d46088996ae3cfaf370e5edde8b6eff8b9 "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -6479,4 +6470,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index c903be2f..6c8a3568 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -12,4 +12,4 @@ "submit": "Submit", "emptyCourseNameError": "Course name should not be empty" } -} +} \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index cc5fd269..e62cf43d 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -12,4 +12,4 @@ "submit": "Opslaan", "emptyCourseNameError": "Vak naam mag niet leeg zijn" } -} +} \ No newline at end of file diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index f1661e80..a64ebb5c 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,14 +1,12 @@ -import { useTranslation } from "react-i18next"; /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { - const { t } = useTranslation(); return (

); -} +} \ No newline at end of file From f8afbe116ab063b3e49e665a922d2be46e64058a Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 4 Apr 2024 22:59:29 +0200 Subject: [PATCH 249/377] Hope this fixes it --- frontend/package-lock.json | 2 +- frontend/src/pages/home/Home.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da948817..09f79e1d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6470,4 +6470,4 @@ } } } -} \ No newline at end of file +} diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index a64ebb5c..03358a3d 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -9,4 +9,4 @@ export default function Home() {

); -} \ No newline at end of file +} From 78eb9c732c24bd6d7311026cf8d9fc1248552d5d Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 5 Apr 2024 08:38:03 +0200 Subject: [PATCH 250/377] Typing and comments --- backend/tests/conftest.py | 4 ++-- backend/tests/endpoints/conftest.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 628ff4f1..8904e884 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -28,7 +28,7 @@ def app() -> Generator[Flask, any, None]: yield app @fixture -def client(app) -> Generator[any, any, None]: +def client(app: Flask) -> Generator[any, any, None]: """Yield a test client""" with app.test_client() as client: with app.app_context(): @@ -69,7 +69,7 @@ def session() -> Generator[Session, any, None]: ### AUTHENTICATION & AUTHORIZATION ### @fixture(autouse=True) # Always run this before a test -def auth_tokens(session) -> None: +def auth_tokens(session: Session) -> None: """Add the authenticated users to the database""" session.add_all([ diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 61b8b74a..3317d18c 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -1,4 +1,4 @@ -"""Pytest fixtures""" +"""Endpoint level fixtures""" import tempfile from datetime import datetime From 8730001c22a404349a0c57bd75a620149d24d7e4 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 5 Apr 2024 09:51:29 +0200 Subject: [PATCH 251/377] updating tests --- backend/tests/endpoints/course/courses_test.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 55f72d9b..2d1d623a 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -66,7 +66,7 @@ def test_get_courses_all(self, client: FlaskClient, courses: List[Course]): data = [course["name"] for course in response.json["data"]] assert all(course.name in data for course in courses) - def test_get_courses_wrong_parameter(self, client: FlaskClient): + def test_get_courses_wrong_argument(self, client: FlaskClient): """Test getting courses for a wrong parameter""" response = client.get("/courses?parameter=0", headers = {"Authorization": "student"}) assert response.status_code == 400 @@ -285,7 +285,7 @@ def test_patch_course_incorrect_field(self, client: FlaskClient, course: Course) response = client.patch( f"/courses/{course.course_id}", headers = {"Authorization": "teacher"}, - json = {"field": 0} + json = {"incorrect": 0} ) assert response.status_code == 400 @@ -386,7 +386,7 @@ def test_post_students_incorrect_field( assert response.status_code == 400 def test_post_students_correct( - self, client: FlaskClient, course: Course, student_other: User + self, client: FlaskClient, api_host: str, course: Course, student_other: User ): """Test adding students to a course""" response = client.post( @@ -397,8 +397,7 @@ def test_post_students_correct( } ) assert response.status_code == 201 - data = response.json["data"][0] - assert not data + assert response.json["data"]["students"][0] == f"{api_host}/users/student_other" @@ -584,7 +583,7 @@ def test_delete_admins_correct(self, client: FlaskClient, course: Course, admin: "admin_uid": admin.uid } ) - assert response.status_code == 200 + assert response.status_code == 204 response = client.get( f"/courses/{course.course_id}/admins", headers = {"Authorization": "teacher"} From 3d10ef7cf7a99b009a1883d6dce9a0b674c7f2c5 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sat, 6 Apr 2024 15:36:59 +0200 Subject: [PATCH 252/377] Set title of header using Portal (#177) * Set title using Portal * cleanup --- frontend/public/locales/en/translation.json | 3 ++ frontend/public/locales/nl/translation.json | 3 ++ frontend/src/components/Header/Header.tsx | 26 ++-------- frontend/src/components/Header/PageTitle.tsx | 14 +++++ frontend/src/components/Header/Title.tsx | 51 +++++++++++++++++++ .../src/components/Header/TitlePortal.tsx | 14 +++++ frontend/src/pages/home/Home.tsx | 12 +++-- 7 files changed, 96 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/Header/PageTitle.tsx create mode 100644 frontend/src/components/Header/Title.tsx create mode 100644 frontend/src/components/Header/TitlePortal.tsx diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 6c8a3568..3ad744c2 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1,4 +1,7 @@ { + "home": { + "title": "Homepage" + }, "header": { "myProjects": "My Projects", "myCourses": "My Courses", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index e62cf43d..5135d0a9 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -1,4 +1,7 @@ { + "home": { + "title": "Homepagina" + }, "header": { "myProjects": "Mijn Projecten", "myCourses": "Mijn Vakken", diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index f7cdb63f..6be68f92 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -17,7 +17,8 @@ import MenuIcon from "@mui/icons-material/Menu"; import { useTranslation } from "react-i18next"; import { useEffect, useState } from "react"; import LanguageIcon from "@mui/icons-material/Language"; -import { Link, useLocation } from "react-router-dom"; +import { Link } from "react-router-dom"; +import { TitlePortal } from "./TitlePortal"; /** * The header component for the application that will be rendered at the top of the page. @@ -41,7 +42,6 @@ export function Header(): JSX.Element { setLanguageMenuAnchor(null); }; - const location = useLocation(); const [open, setOpen] = useState(false); const [listItems, setListItems] = useState([ { link: "/", text: t("homepage") } @@ -61,8 +61,6 @@ export function Header(): JSX.Element { } }, [t]); - const title = getTitle(location.pathname, t); - return ( @@ -70,9 +68,7 @@ export function Header(): JSX.Element { setOpen(!open)} sx={{ color: "white", marginLeft: 0 }}> - - {title} - +
@@ -107,22 +103,6 @@ function isLoggedIn() { return true; } -/** - * Get the title based on the given pathname. - * @param pathname - The current pathname. - * @param t - The translation function. - * @returns The title. - */ -function getTitle(pathname: string, t: (key: string) => string): string { - switch(pathname) { - case '/': return t('home'); - case '/login': return t('login'); - case '/courses': return t('myCourses'); - case '/projects': return t('myProjects'); - default: return t('home'); - } -} - /** * Renders the drawer menu component. * @param open - Whether the drawer menu is open or not. diff --git a/frontend/src/components/Header/PageTitle.tsx b/frontend/src/components/Header/PageTitle.tsx new file mode 100644 index 00000000..c471095d --- /dev/null +++ b/frontend/src/components/Header/PageTitle.tsx @@ -0,0 +1,14 @@ +export const PageTitle = ({ title, defaultTitle, className }: {title:string,defaultTitle:string,className:string}) => { + + return ( + + {!title ? ( + {defaultTitle} + ) : typeof title === 'string' ? ( + {title} + ) : ( + title + )} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/Header/Title.tsx b/frontend/src/components/Header/Title.tsx new file mode 100644 index 00000000..0b014b00 --- /dev/null +++ b/frontend/src/components/Header/Title.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; + +export const Title = (props: TitleProps) => { + const { defaultTitle, title } = props; + const [container, setContainer] = useState(() => + typeof document !== 'undefined' + ? document.getElementById('react-admin-title') + : null + ); + + // on first mount, we don't have the container yet, so we wait for it + useEffect(() => { + setContainer(container => { + const isInTheDom = + typeof document !== 'undefined' && + document.body.contains(container); + if (container && isInTheDom) return container; + return typeof document !== 'undefined' + ? document.getElementById('react-admin-title') + : null; + }); + }, []); + + if (!container) return null; + + return createPortal( + <>{title || defaultTitle}, + container + ); +}; + +export const TitlePropType = PropTypes.oneOfType([ + PropTypes.string, + PropTypes.element, +]); + +Title.propTypes = { + defaultTitle: PropTypes.string, + className: PropTypes.string, + record: PropTypes.any, + title: TitlePropType, +}; + +export interface TitleProps { + className?: string; + defaultTitle?: string; + title?: string; + preferenceKey?: string; +} \ No newline at end of file diff --git a/frontend/src/components/Header/TitlePortal.tsx b/frontend/src/components/Header/TitlePortal.tsx new file mode 100644 index 00000000..edb9502b --- /dev/null +++ b/frontend/src/components/Header/TitlePortal.tsx @@ -0,0 +1,14 @@ +import { Typography, TypographyProps } from '@mui/material'; + +export const TitlePortal = (props: TypographyProps) => ( + +); \ No newline at end of file diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 03358a3d..53bce51d 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,12 +1,16 @@ - +import { useTranslation } from "react-i18next"; +import { Title } from "../../components/Header/Title"; /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { + const { t } = useTranslation("translation", { keyPrefix: "home" }); return ( -
-

-
+ <> + + <div> + </div> + </> ); } From 3bd4bf3c1a1b381b4cc0563c58dce1e77c64ef8c Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 6 Apr 2024 19:51:30 +0200 Subject: [PATCH 253/377] Removed paths from db for projects and allowing multiple deadlines (#185) * Fix #181 * resolved syntax errors * Fix #181 * removed unused statement * resolved linting issues * removed print statement --- backend/db_construct.sql | 10 ++++++---- .../endpoints/projects/endpoint_parser.py | 20 ++++++++++++++----- .../endpoints/projects/project_detail.py | 2 +- .../project/endpoints/projects/projects.py | 9 ++++----- backend/project/models/project.py | 16 +++++++++++---- backend/requirements.txt | 1 + backend/tests/conftest.py | 10 ++-------- backend/tests/endpoints/conftest.py | 5 +---- backend/tests/endpoints/project_test.py | 4 ++++ backend/tests/models/project_test.py | 5 +---- 10 files changed, 47 insertions(+), 35 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index e3f6af41..347354fd 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -38,17 +38,19 @@ CREATE TABLE course_students ( PRIMARY KEY(course_id, uid) ); +CREATE TYPE deadline AS( + description TEXT, + deadline TIMESTAMP WITH TIME ZONE +); + CREATE TABLE projects ( project_id INT GENERATED ALWAYS AS IDENTITY, title VARCHAR(50) NOT NULL, description TEXT NOT NULL, - assignment_file VARCHAR(50), - deadline TIMESTAMP WITH TIME ZONE, + deadlines deadline[], course_id INT NOT NULL, visible_for_students BOOLEAN NOT NULL, archived BOOLEAN NOT NULL, - test_path VARCHAR(50), - script_name VARCHAR(50), regex_expressions VARCHAR(50)[], PRIMARY KEY(project_id), CONSTRAINT fk_course FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index d9737826..f4ab93ea 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -2,6 +2,7 @@ Parser for the argument when posting or patching a project """ +import json from flask_restful import reqparse from werkzeug.datastructures import FileStorage @@ -14,7 +15,7 @@ help='Projects assignment file', location="form" ) -parser.add_argument("deadline", type=str, help='Projects deadline', location="form") +parser.add_argument('deadlines', type=str, help='Projects deadlines', location="form") parser.add_argument("course_id", type=str, help='Projects course_id', location="form") parser.add_argument( "visible_for_students", @@ -23,8 +24,6 @@ location="form" ) parser.add_argument("archived", type=bool, help='Projects', location="form") -parser.add_argument("test_path", type=str, help='Projects test path', location="form") -parser.add_argument("script_name", type=str, help='Projects test script path', location="form") parser.add_argument( "regex_expressions", type=str, @@ -39,9 +38,20 @@ def parse_project_params(): """ args = parser.parse_args() result_dict = {} - for key, value in args.items(): if value is not None: - result_dict[key] = value + if "deadlines" == key: + deadlines_parsed = json.loads(value) + new_deadlines = [] + for deadline in deadlines_parsed: + new_deadlines.append( + ( + deadline["description"], + deadline["deadline"] + ) + ) + result_dict[key] = new_deadlines + else: + result_dict[key] = value return result_dict diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index 060587c7..d2affa57 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -89,7 +89,7 @@ def patch(self, project_id): zip_location = os.path.join(project_upload_directory, filename) with zipfile.ZipFile(zip_location) as upload_zip: upload_zip.extractall(project_upload_directory) - project_json["assignment_file"] = filename + except zipfile.BadZipfile: db.session.rollback() return ({ diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 835e692d..01873379 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -38,7 +38,7 @@ def get(self, teacher_id=None): return query_selected_from_model( Project, response_url, - select_values=["project_id", "title", "description", "deadline"], + select_values=["project_id", "title", "description", "deadlines"], url_mapper={"project_id": response_url}, filters=request.args ) @@ -55,7 +55,6 @@ def post(self, teacher_id=None): if "assignment_file" in request.files: file = request.files["assignment_file"] filename = os.path.basename(file.filename) - project_json["assignment_file"] = filename # save the file that is given with the request try: @@ -81,9 +80,9 @@ def post(self, teacher_id=None): os.makedirs(project_upload_directory, exist_ok=True) if filename is not None: try: - file.save(os.path.join(project_upload_directory, filename)) - zip_location = os.path.join(project_upload_directory, filename) - with zipfile.ZipFile(zip_location) as upload_zip: + file_path = os.path.join(project_upload_directory, filename) + file.save(file_path) + with zipfile.ZipFile(file_path) as upload_zip: upload_zip.extractall(project_upload_directory) except zipfile.BadZipfile: os.remove(os.path.join(project_upload_directory, filename)) diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 8ba901ff..624f9ed0 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy_utils import CompositeType from project.db_in import db @dataclass @@ -23,11 +24,18 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes project_id: int = Column(Integer, primary_key=True) title: str = Column(String(50), nullable=False, unique=False) description: str = Column(Text, nullable=False) - assignment_file: str = Column(String(50)) - deadline: str = Column(DateTime(timezone=True)) + deadlines: list = Column(ARRAY( + CompositeType( + "deadline", + [ + Column("description", Text), + Column("deadline", DateTime(timezone=True)) + ] + ), + dimensions=1 + ) + ) course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) visible_for_students: bool = Column(Boolean, nullable=False) archived: bool = Column(Boolean, nullable=False) - test_path: str = Column(String(50)) - script_name: str = Column(String(50)) regex_expressions: list[str] = Column(ARRAY(String(50))) diff --git a/backend/requirements.txt b/backend/requirements.txt index f47e98e6..0c8d687f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ flask~=3.0.2 flask-cors flask-restful flask-sqlalchemy +sqlalchemy_utils python-dotenv~=1.0.1 psycopg2-binary pytest~=8.0.1 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a7cc092b..f7bacfa3 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -69,25 +69,19 @@ def projects(session): Project( title="B+ Trees", description="Implement B+ trees", - assignment_file="assignement.pdf", - deadline=datetime(2024,3,15,13,0,0), + deadlines=[("Deadline 1",datetime(2024,3,15,13,0,0))], course_id=course_id_ad3, visible_for_students=True, archived=False, - test_path="/tests", - script_name="script.sh", regex_expressions=["solution"] ), Project( title="Predicaten", description="Predicaten project", - assignment_file="assignment.pdf", - deadline=datetime(2023,3,15,13,0,0), + deadlines=[("Deadline 1", datetime(2023,3,15,13,0,0))], course_id=course_id_raf, visible_for_students=False, archived=True, - test_path="/tests", - script_name="script.sh", regex_expressions=[".*"] ) ] diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index fd46dd8a..401de3d0 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -190,13 +190,10 @@ def valid_project(valid_course_entry): data = { "title": "Project", "description": "Test project", - "assignment_file": "testfile", - "deadline": "2024-02-25T12:00:00", + "deadlines": [{"deadline": "2024-02-25T12:00:00", "description": "Deadline 1"}], "course_id": valid_course_entry.course_id, "visible_for_students": True, "archived": False, - "test_path": "tests", - "script_name": "script.sh", "regex_expressions": ["*.pdf", "*.txt"] } return data diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 46f7bcbc..510e24ce 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,10 +1,13 @@ """Tests for project endpoints.""" +import json + def test_assignment_download(client, valid_project): """ Method for assignment download """ + valid_project["deadlines"] = json.dumps(valid_project["deadlines"]) with open("tests/resources/testzip.zip", "rb") as zip_file: valid_project["assignment_file"] = zip_file # post the project @@ -48,6 +51,7 @@ def test_getting_all_projects(client): def test_post_project(client, valid_project): """Test posting a project to the database and testing if it's present""" + valid_project["deadlines"] = json.dumps(valid_project["deadlines"]) with open("tests/resources/testzip.zip", "rb") as zip_file: valid_project["assignment_file"] = zip_file # post the project diff --git a/backend/tests/models/project_test.py b/backend/tests/models/project_test.py index b99a6134..cda7c562 100644 --- a/backend/tests/models/project_test.py +++ b/backend/tests/models/project_test.py @@ -16,13 +16,10 @@ def test_create_project(self, session: Session): project = Project( title="Pigeonhole", description="A new project", - assignment_file="assignment.pdf", - deadline=datetime(2024,12,31,23,59,59), + deadlines=[("Deadline 1", datetime(2024,12,31,23,59,59))], course_id=course.course_id, visible_for_students=True, archived=False, - test_path="/test", - script_name="script", regex_expressions=[r".*"] ) session.add(project) From 1898ed3d03ec4c07f928b8f9e9179c8a547fd964 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Mon, 8 Apr 2024 10:07:35 +0200 Subject: [PATCH 254/377] Created dynamic deadline calender view/input (#190) * Fix #188 * added mui date time components to dependencies * fixed editable not being passed as prop --- frontend/package-lock.json | 70 ++++- frontend/package.json | 2 + .../components/Calender/DeadlineCalender.tsx | 251 ++++++++++++++++++ frontend/src/types/deadline.ts | 4 + 4 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Calender/DeadlineCalender.tsx create mode 100644 frontend/src/types/deadline.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 09f79e1d..abde71c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,8 @@ "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", + "@mui/x-date-pickers": "^7.1.1", + "dayjs": "^1.11.10", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", "jszip": "^3.10.1", @@ -1534,6 +1536,71 @@ } } }, + "node_modules/@mui/x-date-pickers": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.1.1.tgz", + "integrity": "sha512-doSaoNfYR4nAXSN2mz5MwktYUmPt37jZ8/t5QrPgFtEFc3KWZoBps0YEcno5qUynY1ISpOjvnVr18zqszzG+RA==", + "dependencies": { + "@babel/runtime": "^7.24.0", + "@mui/base": "^5.0.0-beta.40", + "@mui/system": "^5.15.14", + "@mui/utils": "^5.15.14", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14", + "date-fns": "^2.25.0 || ^3.2.0", + "date-fns-jalali": "^2.13.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2955,8 +3022,7 @@ "node_modules/dayjs": { "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", - "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==", - "dev": true + "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, "node_modules/debug": { "version": "4.3.4", diff --git a/frontend/package.json b/frontend/package.json index 825d4653..3d1fd7d6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", + "@mui/x-date-pickers": "^7.1.1", + "dayjs": "^1.11.10", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", "jszip": "^3.10.1", diff --git a/frontend/src/components/Calender/DeadlineCalender.tsx b/frontend/src/components/Calender/DeadlineCalender.tsx new file mode 100644 index 00000000..86948bcd --- /dev/null +++ b/frontend/src/components/Calender/DeadlineCalender.tsx @@ -0,0 +1,251 @@ +import { + DateCalendar, + LocalizationProvider, + PickersDay, + PickersDayProps, + TimeField, +} from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { Dayjs } from "dayjs"; +import { Deadline } from "../../types/deadline"; +import { + Badge, + Box, + Divider, + Grid, + IconButton, + List, + ListItem, + Menu, + TextField, + Typography, +} from "@mui/material"; +import { useState } from "react"; +import SendIcon from "@mui/icons-material/Send"; +import ClearIcon from "@mui/icons-material/Clear"; +import { useTranslation } from "react-i18next"; + +interface DeadlineCalenderProps { + deadlines: Deadline[]; + onChange: (deadline: Deadline[]) => void; + editable?: boolean; +} + +/** + * + * @param params - The deadlines and the onChange function + * @returns - The DeadlineCalender component that displays the deadlines + */ +export default function DeadlineCalender({ + deadlines, + onChange, + editable = false, +}: DeadlineCalenderProps) { + const [deadlinesS, setDeadlines] = useState<Deadline[]>(deadlines); + const { i18n } = useTranslation(); + + const handleNewDeadline = (deadline: Deadline) => { + const newDeadlines = [...deadlinesS, deadline]; + setDeadlines(newDeadlines); + onChange(newDeadlines); + }; + + const handleDeadlineRemoved = (deadline: Deadline) => { + const newDeadlines = deadlinesS.filter((d) => d !== deadline); + setDeadlines(newDeadlines); + onChange(newDeadlines); + }; + + return ( + <LocalizationProvider + dateAdapter={AdapterDayjs} + adapterLocale={i18n.language} + > + <DateCalendar + slots={{ day: DeadlineDay }} + slotProps={{ + day: { + deadlines: deadlinesS, + handleNewDeadline, + handleDeadlineRemoved, + editable + } as any // eslint-disable-line @typescript-eslint/no-explicit-any, + }} + /> + </LocalizationProvider> + ); +} + +/** + * + * @param props - The day and the deadlines + * @returns - The DeadlineDay component that displays the deadlines for a specific day + */ +function DeadlineDay( + props: PickersDayProps<Dayjs> & { + deadlines?: Deadline[]; + editable?: boolean; + handleNewDeadline?: (deadline: Deadline) => void; + handleDeadlineRemoved?: (deadline: Deadline) => void; + } +) { + const { + deadlines = [], + day, + outsideCurrentMonth, + editable = false, + handleNewDeadline, + handleDeadlineRemoved, + ...other + } = props; + const [descriptionMenuAnchor, setDescriptionMenuAnchor] = + useState<null | HTMLElement>(null); + + const handleDescriptionMenu = (event: React.MouseEvent<HTMLElement>) => { + setDescriptionMenuAnchor(event.currentTarget); + }; + + const handleCloseDescriptionMenu = () => { + setDescriptionMenuAnchor(null); + }; + + const isDeadline = + !outsideCurrentMonth && + deadlines.filter((deadline) => { + return dayjs(deadline.deadline).isSame(day, "day"); + }).length > 0; + + return ( + <Badge + badgeContent={isDeadline ? "🔵" : undefined} + key={day.toString()} + overlap="circular" + > + <PickersDay + {...other} + onClick={handleDescriptionMenu} + day={day} + outsideCurrentMonth={outsideCurrentMonth} + /> + {(isDeadline || editable) && ( + <Menu + anchorEl={descriptionMenuAnchor} + open={Boolean(descriptionMenuAnchor)} + onClose={handleCloseDescriptionMenu} + > + <DeadlineDescriptionMenu + deadlines={deadlines} + day={day} + editable={editable} + onNewDeadline={handleNewDeadline} + onDeadlineRemoved={handleDeadlineRemoved} + /> + </Menu> + )} + </Badge> + ); +} + +/** + * + * @param params - The deadlines, the day, editable, onNewDeadline and onDeadlineRemoved functions + * @returns - The DeadlineDescriptionMenu component that displays the deadlines for a specific day + */ +function DeadlineDescriptionMenu({ + deadlines, + day, + editable, + onNewDeadline, + onDeadlineRemoved, +}: { + deadlines: Deadline[]; + day: Dayjs; + editable: boolean; + onNewDeadline?: (deadline: Deadline) => void; + onDeadlineRemoved?: (deadline: Deadline) => void; +}) { + const [description, setDescription] = useState<string>(""); + const [time, setTime] = useState<Dayjs | null>(null); + + const handleNewDeadline = () => { + if (time && onNewDeadline && description.length > 0) { + let newDeadline = day.clone(); + newDeadline = newDeadline.hour(time.hour()); + newDeadline = newDeadline.minute(time.minute()); + console.log(newDeadline.isSame(day, "day")); + onNewDeadline({ deadline: newDeadline.toString(), description }); + } + setDescription(""); + setTime(null); + }; + + const handleDeadlineRemoved = (deadline: Deadline) => { + if (onDeadlineRemoved) { + onDeadlineRemoved(deadline); + } + }; + + return ( + <Grid container direction="column" gap="1rem" width="20vw"> + <Grid item> + <List> + {deadlines + .filter((deadline) => { + return dayjs(deadline.deadline).isSame(day, "day"); + }) + .map((deadline, index) => { + return ( + <div key={index}> + <ListItem> + {deadline.description} + <Box flexGrow={1} /> + <Typography> + {dayjs(deadline.deadline).format("HH:mm")} + </Typography> + {editable && ( + <IconButton + onClick={() => handleDeadlineRemoved(deadline)} + > + <ClearIcon /> + </IconButton> + )} + </ListItem> + <Divider /> + </div> + ); + })} + </List> + </Grid> + {editable && ( + <Grid item marginLeft="1rem"> + <Grid container spacing="1rem"> + <Grid item> + <TextField + id="new-discription-field" + variant="outlined" + size="small" + value={description} + onChange={(event) => setDescription(event.target.value)} + /> + </Grid> + <Grid item sm={3.3}> + <TimeField + format="HH:mm" + size="small" + value={time} + onChange={(newValue) => { + setTime(newValue); + }} + /> + </Grid> + <Grid item> + <IconButton onClick={handleNewDeadline}> + <SendIcon /> + </IconButton> + </Grid> + </Grid> + </Grid> + )} + </Grid> + ); +} diff --git a/frontend/src/types/deadline.ts b/frontend/src/types/deadline.ts new file mode 100644 index 00000000..80200211 --- /dev/null +++ b/frontend/src/types/deadline.ts @@ -0,0 +1,4 @@ +export interface Deadline { + deadline: string; + description: string; +} \ No newline at end of file From bdde5d7cddfab291ce2a89857491b0a9174c5f99 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Tue, 9 Apr 2024 14:09:23 +0200 Subject: [PATCH 255/377] Backend/feature/submission evaluation (#67) * added docker to requirements * added: functionality to run a dockerized tester on a given submission * added tests for submission evaluators * moved file handling functions to seperate file * added interpretor path * updated dependencies * fixed: linting * added init file for module recognition * CMD isn't used anymore as it is provided by the compose * not running utils in docker as it uses docker internally and we don't yet have support for dind * switched to LF * changed: running tests not linting * subfolders for submissions are nog longer made by the evaluator * removed unused import statement * linting: added blank line * added ussage of requirements manifest which allows teachers to manifest which requirements are allowed to be used * added: helper functions to prepare test cases * added test case for working with requirements * disabled pylinter for evaluator test resources * fixed: linter ignoring all files * using pylint recursive argument instead * resolved merging issues * using docker in docker image instead * no longer binding socket to image * passing path instead of project * hotfix * binding docker daemon * starting dind docker daemon in entrypoint script * removed unused env variable * running all tests * fixed linting --------- Co-authored-by: Aron Buzogany <aronsaps@gmail.com> Co-authored-by: abuzogan <aron.buzogany@ugent.be> --- backend/Dockerfile | 13 ++- backend/Dockerfile.test | 26 +++-- backend/entry-point.sh | 12 +++ backend/project/utils/__init__.py | 0 backend/project/utils/submissions/__init__.py | 0 .../project/utils/submissions/evaluator.py | 97 +++++++++++++++++++ .../submissions/evaluators/python/Dockerfile | 3 + .../evaluators/python/entry_point.sh | 40 ++++++++ .../utils/submissions/file_handling.py | 47 +++++++++ backend/pylintrc | 1 + backend/requirements.txt | 1 + backend/tests.yaml | 3 +- backend/tests/utils/__init__.py | 0 .../utils/submission_evaluators/__init__.py | 0 .../base_evaluators_test.py | 19 ++++ .../utils/submission_evaluators/conftest.py | 44 +++++++++ .../submission_evaluators/python_test.py | 76 +++++++++++++++ .../resources/python/__init__.py | 0 .../python/tc_1/assignment/run_test.sh | 3 + .../python/tc_2/assignment/run_test.sh | 3 + .../tc_2/submission/submission/hello_world.py | 4 + .../submission/submission/requirements.txt | 1 + .../python/tc_3/assignment/req-manifest.txt | 0 .../python/tc_3/assignment/run_test.sh | 3 + .../tc_3/submission/submission/hello_world.py | 4 + .../submission/submission/requirements.txt | 1 + 26 files changed, 392 insertions(+), 9 deletions(-) create mode 100755 backend/entry-point.sh create mode 100644 backend/project/utils/__init__.py create mode 100644 backend/project/utils/submissions/__init__.py create mode 100644 backend/project/utils/submissions/evaluator.py create mode 100644 backend/project/utils/submissions/evaluators/python/Dockerfile create mode 100644 backend/project/utils/submissions/evaluators/python/entry_point.sh create mode 100644 backend/project/utils/submissions/file_handling.py create mode 100644 backend/tests/utils/__init__.py create mode 100644 backend/tests/utils/submission_evaluators/__init__.py create mode 100644 backend/tests/utils/submission_evaluators/base_evaluators_test.py create mode 100644 backend/tests/utils/submission_evaluators/conftest.py create mode 100644 backend/tests/utils/submission_evaluators/python_test.py create mode 100644 backend/tests/utils/submission_evaluators/resources/python/__init__.py create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.sh create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_test.sh create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/hello_world.py create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/requirements.txt create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/req-manifest.txt create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_test.sh create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/hello_world.py create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/requirements.txt diff --git a/backend/Dockerfile b/backend/Dockerfile index 558bf735..462d840e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,9 +1,20 @@ +FROM docker:dind as builder + FROM python:3.9 + +COPY --from=builder /usr/local/bin/docker /usr/local/bin/docker + RUN mkdir /app WORKDIR /app + ADD ./project /app/ + COPY requirements.txt /app/requirements.txt + RUN pip3 install -r requirements.txt + COPY . /app + ENTRYPOINT ["python"] -CMD ["__main__.py"] \ No newline at end of file + +CMD ["__main__.py"] diff --git a/backend/Dockerfile.test b/backend/Dockerfile.test index 92ec8b63..82abc168 100644 --- a/backend/Dockerfile.test +++ b/backend/Dockerfile.test @@ -1,12 +1,24 @@ -FROM python:3.9-slim +FROM docker:dind -# Set the working directory -WORKDIR /app +RUN apk add --no-cache \ + python3 \ + py3-pip \ + tzdata + +ENV TZ=UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + + + +RUN python3 -m venv /venv +ENV PATH="/venv/bin:$PATH" -# Copy the application code into the container COPY . /app -# Install dependencies -RUN apt-get update -RUN apt-get install -y --no-install-recommends python3-pip +WORKDIR /app + RUN pip3 install --no-cache-dir -r requirements.txt -r dev-requirements.txt + +RUN chmod +x /app/entry-point.sh + +ENTRYPOINT ["/app/entry-point.sh"] diff --git a/backend/entry-point.sh b/backend/entry-point.sh new file mode 100755 index 00000000..daeb6329 --- /dev/null +++ b/backend/entry-point.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Start the Docker daemon in the background +dockerd-entrypoint.sh & + +# Wait for the Docker daemon to start +until docker info; do + echo "Waiting for Docker daemon to start..." + sleep 1 +done + +# Execute the command passed to the docker run command +exec "$@" diff --git a/backend/project/utils/__init__.py b/backend/project/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/project/utils/submissions/__init__.py b/backend/project/utils/submissions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/project/utils/submissions/evaluator.py b/backend/project/utils/submissions/evaluator.py new file mode 100644 index 00000000..e2ebca00 --- /dev/null +++ b/backend/project/utils/submissions/evaluator.py @@ -0,0 +1,97 @@ +""" +This module is responsible for evaluating the submission. +It uses docker to run the evaluator in a container. +The image used for the container is determined by the evaluator argument. +If the evaluator is not found in the +DOCKER_IMAGE_MAPPER, the project test path is used as the image. +The evaluator is run in the container and the +exit code is returned. The output of the evaluator is written to a log file +in the submission output folder. +""" +from os import path +import docker +from project.models.submission import Submission + +DOCKER_IMAGE_MAPPER = { + "python": path.join(path.dirname(__file__), "evaluators", "python"), +} + + +def evaluate(submission: Submission, project_path: str, evaluator: str) -> int: + """ + Evaluate a submission using the evaluator. + + Args: + submission (Submissions): The submission to evaluate. + project_path (str): The path to the project. + evaluator (str): The evaluator to use. + + Returns: + int: The exit code of the evaluator. + + Raises: + ValueError: If the evaluator is not found in the DOCKER_IMAGE_MAPPER + and the project test path does not exist. + """ + + docker_image = DOCKER_IMAGE_MAPPER.get(evaluator, None) + if docker_image is None: + docker_image = project_path + if not path.exists(docker_image): + raise ValueError(f"Test path: {docker_image},\ + not found and the provided evaluator:\ + {evaluator} is not associated with any image.") + + submission_path = submission.submission_path + submission_solution_path = path.join(submission_path, "submission") + + container = create_and_run_evaluator(docker_image, + submission.submission_id, + project_path, + submission_solution_path) + + submission_output_path = path.join(submission_path, "output") + test_output_path = path.join(submission_output_path, "test_output.log") + + exit_code = container.wait() + + with open(path.join(test_output_path), "w", encoding='utf-8') as output_file: + output_file.write(container.logs().decode('utf-8')) + + container.remove() + + return exit_code['StatusCode'] + +def create_and_run_evaluator(docker_image: str, + submission_id: int, + project_path: str, + submission_solution_path: str): + """ + Create and run the evaluator container. + + Args: + docker_image (str): The path to the docker image. + submission_id (int): The id of the submission. + project_path (str): The path to the project. + submission_solution_path (str): The path to the submission solution. + + Returns: + docker.models.containers.Container: The container that is running the evaluator. + """ + client = docker.from_env() + image, _ = client.images.build(path=docker_image, tag=f"submission_{submission_id}") + + + container = client.containers.run( + image.id, + detach=True, + command="bash entry_point.sh", + volumes={ + path.abspath(project_path): {'bind': "/tests", 'mode': 'rw'}, + path.abspath(submission_solution_path): {'bind': "/submission", 'mode': 'rw'} + }, + stderr=True, + stdout=True, + pids_limit=256 + ) + return container diff --git a/backend/project/utils/submissions/evaluators/python/Dockerfile b/backend/project/utils/submissions/evaluators/python/Dockerfile new file mode 100644 index 00000000..f8e09927 --- /dev/null +++ b/backend/project/utils/submissions/evaluators/python/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.9-slim + +COPY . . \ No newline at end of file diff --git a/backend/project/utils/submissions/evaluators/python/entry_point.sh b/backend/project/utils/submissions/evaluators/python/entry_point.sh new file mode 100644 index 00000000..e24a883a --- /dev/null +++ b/backend/project/utils/submissions/evaluators/python/entry_point.sh @@ -0,0 +1,40 @@ +#!/bin/bash + + +tests_manifest_file="/tests/req-manifest.txt" + +if [ -f "$tests_manifest_file" ]; then + echo "Tests manifest file found. Installing tests requirements..." + pip3 install -r $tests_manifest_file &> /dev/null +else + echo "No tests manifest file found." + submission_requirements_file="/submission/requirements.txt" + if [ -f "$submission_requirements_file" ]; then + echo "Requirements file found. Installing requirements..." + pip3 install -r $submission_requirements_file &> /dev/null + else + echo "No requirements file found." + fi + + submission_dev_requirements_file="/submission/dev-requirements.txt" + + if [ -f "$submission_dev_requirements_file" ]; then + echo "Dev requirements file found. Installing dev requirements..." + pip3 install -r $submission_dev_requirements_file &> /dev/null + else + echo "No dev requirements file found." + fi + + tests_requirements_file="/tests/requirements.txt" + + if [ -f "$tests_requirements_file" ]; then + echo "Tests requirements file found. Installing tests requirements..." + pip3 install -r $tests_requirements_file &> /dev/null + else + echo "No tests requirements file found." + fi +fi + +echo "Running tests..." +ls /submission +bash /tests/run_test.sh \ No newline at end of file diff --git a/backend/project/utils/submissions/file_handling.py b/backend/project/utils/submissions/file_handling.py new file mode 100644 index 00000000..39a018e8 --- /dev/null +++ b/backend/project/utils/submissions/file_handling.py @@ -0,0 +1,47 @@ +""" +This module contains functions for handling files and folders for submissions. +""" + +from os import path, makedirs, getenv + +def create_submission_subfolders(submission_path: str): + """ + Create the output and artifacts folder for a submission. + """ + submission_output_path = path.join(submission_path, "output") + artifacts_path = path.join(submission_output_path, "artifacts") + submission_solution_path = path.join(submission_path, "submission") + + if not path.exists(submission_solution_path): + makedirs(submission_solution_path) + + if not path.exists(submission_output_path): + makedirs(submission_output_path) + + if not path.exists(artifacts_path): + makedirs(artifacts_path) + + return submission_output_path + +def create_submission_folder(submission_id: int, project_id: int): + """ + Create the submission folder and the submission + solution folder that will contain a students solution. + + Args: + submission_id (int): The id of the submission. + project_id (int): The id of the project. + + Returns: + str: The path to the submission folder. + """ + submission_path = path.join(getenv("SUBMISSIONS_ROOT_PATH"), + str(project_id), + str(submission_id)) + + if not path.exists(submission_path): + makedirs(submission_path) + + create_submission_subfolders(submission_path) + + return submission_path diff --git a/backend/pylintrc b/backend/pylintrc index e47e2d38..2a73b4f9 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -1,5 +1,6 @@ [MASTER] init-hook='import sys; sys.path.append(".")' +ignore-paths=tests/utils/submission_evaluators/resources/.* [MESSAGES CONTROL] disable=W0621, # Redefining name %r from outer scope (line %s) diff --git a/backend/requirements.txt b/backend/requirements.txt index 0c8d687f..8733be9e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,6 +5,7 @@ flask-sqlalchemy sqlalchemy_utils python-dotenv~=1.0.1 psycopg2-binary +docker pytest~=8.0.1 SQLAlchemy~=2.0.27 requests>=2.31.0 diff --git a/backend/tests.yaml b/backend/tests.yaml index 7270889b..5c232c18 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -35,6 +35,7 @@ services: condition: service_healthy auth-server: condition: service_started + privileged: true environment: POSTGRES_HOST: postgres # Use the service name defined in Docker Compose POSTGRES_USER: test_user @@ -47,4 +48,4 @@ services: DOCS_URL: /docs volumes: - .:/app - command: ["pytest"] + command: ["pytest"] \ No newline at end of file diff --git a/backend/tests/utils/__init__.py b/backend/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/utils/submission_evaluators/__init__.py b/backend/tests/utils/submission_evaluators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/utils/submission_evaluators/base_evaluators_test.py b/backend/tests/utils/submission_evaluators/base_evaluators_test.py new file mode 100644 index 00000000..8eac4f5e --- /dev/null +++ b/backend/tests/utils/submission_evaluators/base_evaluators_test.py @@ -0,0 +1,19 @@ +""" +This file contains tests for functions that are applicable to all evaluators. +""" +from os import path +from shutil import rmtree +from project.utils.submissions.file_handling import create_submission_folder + +def test_create_submission_folder_creates(submission_root): + """ + Test whether the create_submission_folder function creates the submission folder. + """ + submission_id = 1 + project_id = 1 + submission_path = create_submission_folder(submission_id, project_id) + assert path.join(submission_path) \ + == path.join(submission_root, str(project_id), str(submission_id)) + assert path.exists(submission_path) + assert path.exists(path.join(submission_path, "submission")) + rmtree(submission_path) diff --git a/backend/tests/utils/submission_evaluators/conftest.py b/backend/tests/utils/submission_evaluators/conftest.py new file mode 100644 index 00000000..92fd363b --- /dev/null +++ b/backend/tests/utils/submission_evaluators/conftest.py @@ -0,0 +1,44 @@ +""" +This file contains the global fixtures for the submission evaluators tests. +""" +from shutil import rmtree +from os import environ, makedirs, path +import pytest +from project.models.submission import Submission +from project.models.project import Project + +@pytest.fixture +def submission_root(): + """ + Create a submission root folder for the tests. + When the tests are done, the folder is removed recursively. + """ + submission_root = path.join(path.dirname(__file__), "submissions-root") + environ["SUBMISSIONS_ROOT_PATH"] = submission_root + makedirs(submission_root, exist_ok=True) + yield submission_root + rmtree(submission_root) + +def prep_submission(submission_root: str) -> tuple[Submission, Project]: + """ + Prepare a submission for testing by creating the appropriate files and + submission and project model objects. + + Args: + submission_root (str): The folder of the submission to prepare. + + Returns: + tuple: The submission and project model objects. + """ + project_id = 2 + submission = Submission(submission_id=2, project_id=project_id) + root = path.join(path.dirname(__file__), "resources", submission_root) + submission.submission_path = path.join(root, "submission") + makedirs(path.join(submission.submission_path, "output"), exist_ok=True) + return submission, path.join(root, "assignment") + +def cleanup_after_test(submission_root: str) -> None: + """ + Remove the submission output root folder after a test. + """ + rmtree(path.join(submission_root, "output")) diff --git a/backend/tests/utils/submission_evaluators/python_test.py b/backend/tests/utils/submission_evaluators/python_test.py new file mode 100644 index 00000000..8db67349 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/python_test.py @@ -0,0 +1,76 @@ +""" +This file contains tests for the python submission evaluator. +""" + +from os import path +import pytest +from project.utils.submissions.evaluator import evaluate +from project.utils.submissions.file_handling import create_submission_folder +from project.models.submission import Submission +from project.models.project import Project +from .conftest import prep_submission, cleanup_after_test + +@pytest.fixture +def project_path_succes(): + """ + Return the path to a project with a succesful test case. + """ + return path.join(path.dirname(__file__), "resources", "python", "tc_1/assignment") + +@pytest.fixture +def evaluate_python(submission_root, project_path_succes): + """Evaluate a python submission with a succesful test case.""" + project_id = 1 + submission = Submission(submission_id=1, project_id=project_id) + submission.submission_path = create_submission_folder(submission_root, project_id) + return evaluate(submission, project_path_succes, "python"), submission.submission_path + +def prep_submission_and_clear_after_py(tc_folder: str) -> tuple[Submission, Project]: + """ + Prepare a submission for testing by creating the appropriate files and + submission and project model objects. + + Args: + tc_folder (str): The folder of the test case to prepare. + + Returns: + tuple: The submission and project model objects. + """ + return prep_submission(path.join("python", tc_folder)) + +def test_base_python_evaluator(evaluate_python): + """Test whether the base python evaluator works.""" + exit_code, _ = evaluate_python + assert exit_code == 0 + +def test_makes_output_folder(evaluate_python): + """Test whether the evaluator makes the output folder.""" + _, submission_path = evaluate_python + assert path.exists(path.join(submission_path, "output")) + +def test_makes_log_file(evaluate_python): + """Test whether the evaluator makes the log file.""" + _, submission_path = evaluate_python + assert path.exists(path.join(submission_path, "output", "test_output.log")) + +def test_logs_output(evaluate_python): + """Test whether the evaluator logs the output of the script.""" + _, submission_path = evaluate_python + with open(path.join(submission_path, "output", "test_output.log",), + "r", + encoding="utf-8") as output_file: + assert "Hello, World!" in output_file.read() + +def test_with_dependency(): + """Test whether the evaluator works with a dependency.""" + submission, project = prep_submission_and_clear_after_py("tc_2") + exit_code = evaluate(submission, project, "python") + cleanup_after_test(submission.submission_path) + assert exit_code == 0 + +def test_dependency_manifest(): + """Test whether the evaluator works with a dependency manifest.""" + submission, project = prep_submission_and_clear_after_py("tc_3") + exit_code = evaluate(submission, project, "python") + cleanup_after_test(submission.submission_path) + assert exit_code != 0 diff --git a/backend/tests/utils/submission_evaluators/resources/python/__init__.py b/backend/tests/utils/submission_evaluators/resources/python/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.sh b/backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.sh new file mode 100644 index 00000000..d2eab023 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.sh @@ -0,0 +1,3 @@ +echo "Hello, World!" + +exit 0 \ No newline at end of file diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_test.sh b/backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_test.sh new file mode 100644 index 00000000..1d2231f3 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_test.sh @@ -0,0 +1,3 @@ + + +python /submission/hello_world.py \ No newline at end of file diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/hello_world.py b/backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/hello_world.py new file mode 100644 index 00000000..eae889e9 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/hello_world.py @@ -0,0 +1,4 @@ +import emoji + +if __name__ == "__main__": + print(emoji.emojize("Hello, World! :red_heart:")) diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/requirements.txt b/backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/requirements.txt new file mode 100644 index 00000000..cdd21623 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/resources/python/tc_2/submission/submission/requirements.txt @@ -0,0 +1 @@ +emoji diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/req-manifest.txt b/backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/req-manifest.txt new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_test.sh b/backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_test.sh new file mode 100644 index 00000000..1d2231f3 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_test.sh @@ -0,0 +1,3 @@ + + +python /submission/hello_world.py \ No newline at end of file diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/hello_world.py b/backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/hello_world.py new file mode 100644 index 00000000..eae889e9 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/hello_world.py @@ -0,0 +1,4 @@ +import emoji + +if __name__ == "__main__": + print(emoji.emojize("Hello, World! :red_heart:")) diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/requirements.txt b/backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/requirements.txt new file mode 100644 index 00000000..cdd21623 --- /dev/null +++ b/backend/tests/utils/submission_evaluators/resources/python/tc_3/submission/submission/requirements.txt @@ -0,0 +1 @@ +emoji From b12cd2710cf78b7c7eac2562c45fe11d1c0528d9 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 9 Apr 2024 16:58:17 +0200 Subject: [PATCH 256/377] Generalizing data field typing tests --- backend/tests/endpoints/conftest.py | 42 ++- .../tests/endpoints/course/courses_test.py | 355 ++++-------------- backend/tests/endpoints/endpoint.py | 64 +++- 3 files changed, 157 insertions(+), 304 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 3317d18c..5799ddee 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,7 +3,7 @@ import tempfile from datetime import datetime from zoneinfo import ZoneInfo -from typing import Tuple, List +from typing import Tuple, List, Dict import pytest from pytest import fixture, FixtureRequest @@ -19,20 +19,42 @@ ### AUTHENTICATION & AUTHORIZATION ### @fixture -def auth_test(request: FixtureRequest, client: FlaskClient, course: Course) -> Tuple: - """Add concrete test data""" - # endpoint, parameters, method, token, status - endpoint, parameters, method, *other = request.param - - d = { - "course_id": course.course_id +def data_map(course: Course) -> Dict[str, any]: + """Map an id to data""" + return { + "@course_id": course.course_id } - for index, parameter in enumerate(parameters): - endpoint = endpoint.replace(f"@{index}", str(d[parameter])) +@fixture +def auth_test(request: FixtureRequest, client: FlaskClient, data_map: Dict[str, any]) -> Tuple: + """Add concrete test data to auth""" + # endpoint, method, token, allowed + endpoint, method, *other = request.param + + for k, v in data_map.items(): + endpoint = endpoint.replace(k, str(v)) return endpoint, getattr(client, method), *other +@fixture +def data_field_test( + request: FixtureRequest, client: FlaskClient, data_map: Dict[str, any] + ) -> Tuple[str, any, str, Dict[str, any], int]: + """Add concrete test data to the data_field tests""" + # endpoint, method, token, data, status + endpoint, method, token, data, status = request.param + + for key, value in data_map.items(): + endpoint = endpoint.replace(key, str(value)) + + for key, value in data.items(): + if isinstance(value, list): + data[key] = [data_map.get(v,v) for v in value] + elif value in data_map.keys(): + data[key] = data_map[value] + + return endpoint, getattr(client, method), token, data, status + ### USERS ### diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 2d1d623a..33df849b 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,9 +1,14 @@ """Tests the courses API endpoint""" -from typing import Tuple, List +from typing import Tuple, List, Dict from pytest import mark from flask.testing import FlaskClient -from tests.endpoints.endpoint import TestEndpoint, authentication_tests, authorization_tests +from tests.endpoints.endpoint import ( + TestEndpoint, + authentication_tests, + authorization_tests, + data_field_tests +) from project.models.user import User from project.models.course import Course @@ -12,37 +17,37 @@ class TestCourseEndpoint(TestEndpoint): ### AUTHENTICATION & AUTHORIZATION ### # Where is login required - # (endpoint, parameters, methods) + # (endpoint, methods) authentication = authentication_tests([ - ("/courses", [], ["get", "post"]), - ("/courses/@0", ["course_id"], ["get", "patch", "delete"]), - ("/courses/@0/students", ["course_id"], ["get", "post", "delete"]), - ("/courses/@0/admins", ["course_id"], ["get", "post", "delete"]) + ("/courses", ["get", "post"]), + ("/courses/@course_id", ["get", "patch", "delete"]), + ("/courses/@course_id/students", ["get", "post", "delete"]), + ("/courses/@course_id/admins", ["get", "post", "delete"]) ]) # Who can access what - # (endpoint, parameters, method, allowed, disallowed) + # (endpoint, method, allowed, disallowed) authorization = authorization_tests([ - ("/courses", [], "get", ["student", "teacher", "admin"], []), - ("/courses", [], "post", ["teacher"], ["student", "admin"]), + ("/courses", "get", ["student", "teacher", "admin"], []), + ("/courses", "post", ["teacher"], ["student", "admin"]), - ("/courses/@0", ["course_id"], "patch", + ("/courses/@course_id", "patch", ["teacher"], ["student", "teacher_other", "admin"]), - ("/courses/@0", ["course_id"], "delete", + ("/courses/@course_id", "delete", ["teacher"], ["student", "teacher_other", "admin"]), - ("/courses/@0/students", ["course_id"], "get", + ("/courses/@course_id/students", "get", ["student", "teacher", "admin"], []), - ("/courses/@0/students", ["course_id"], "post", + ("/courses/@course_id/students", "post", ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), - ("/courses/@0/students", ["course_id"], "delete", + ("/courses/@course_id/students", "delete", ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), - ("/courses/@0/admins", ["course_id"], "get", + ("/courses/@course_id/admins", "get", ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), - ("/courses/@0/admins", ["course_id"], "post", - ["teacher"], ["student", "teacher_other", "admin"]), - ("/courses/@0/admins", ["course_id"], "delete", + ("/courses/@course_id/admins", "post", + ["teacher"], ["student", "admin"]), + ("/courses/@course_id/admins", "delete", ["teacher"], ["student", "teacher_other", "admin"]), ]) @@ -58,6 +63,54 @@ def test_authorization(self, auth_test: Tuple[str, any, str, bool]): + ### DATA & QUERIES ### + # Test a data field + # Other tests to verify the changes for a correct request + # (endpoint, method, token, minimal_data, {key, [(value, status)]}) + data_fields = data_field_tests([ + ("/courses", "post", "teacher", {"name": "test", "ufora_id": "test"}, { + "name": [(None, 400), (0, 400)], + "ufora_id": [(0, 400)], + }), + ("/courses/@course_id", "patch", "teacher", {}, { + "name": [(None, 400), (0, 400)], + "ufora_id": [(0, 400)], + "teacher": [(None, 400), (0, 400), ("student", 400)], + }), + ("/courses/@course_id/students", "post", "teacher", + {"students": ["student_other"]}, + {"students": [(None, 400), ([None], 400), (["no_user"], 400), (["student"], 400)]} + ), + ("/courses/@course_id/students", "delete", "teacher", + {"students": ["student"]}, + {"students": [ + (None, 400), ([None], 400), (["no_user"], 400), (["student_other"], 400) + ]} + ), + ("/courses/@course_id/admins", "post", "teacher", + {"admin_uid": "admin_other"}, + {"admin_uid": [(None, 400), ("no_user", 400), ("student", 400), ("admin", 400)]} + ), + ("/courses/@course_id/admins", "delete", "teacher", + {"admin_uid": ["admin"]}, + {"admin_uid": [(None, 400), ("no_user", 400), ("admin_other", 400)]} + ) + ]) + + # queries = [] + + @mark.parametrize("data_field_test", data_fields, indirect=True) + def test_data_fields(self, data_field_test: Tuple[str, any, str, Dict[str, any], int]): + """Test a data field""" + super().data_field(data_field_test) + + # @mark.parametrize("url_query", queries) + # def test_url_query(self, url_query: Tuple[str]): + # """Test a url query""" + # super().url_query(url_query) + + + ### GET COURSES ### def test_get_courses_all(self, client: FlaskClient, courses: List[Course]): """Test getting all courses""" @@ -170,37 +223,6 @@ def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, course: Co ### POST COURSES ### - def test_post_courses_wrong_name_type(self, client: FlaskClient): - """Test posting a course where the name does not have the correct type""" - response = client.post("/courses", headers = {"Authorization": "teacher"}, - json = { - "name": 0, - "ufora_id": "test" - } - ) - assert response.status_code == 400 - - def test_post_courses_wrong_ufora_id_type(self, client: FlaskClient): - """Test posting a course where the ufora_id does not have the correct type""" - response = client.post("/courses", headers = {"Authorization": "teacher"}, - json = { - "name": "test", - "ufora_id": 0 - } - ) - assert response.status_code == 400 - - def test_post_courses_incorrect_field(self, client: FlaskClient, teacher: User): - """Test posting a course where a field that doesn't occur in the model is given""" - response = client.post("/courses", headers = {"Authorization": "teacher"}, - json = { - "name": "test", - "ufora_id": "test", - "teacher": teacher.uid - } - ) - assert response.status_code == 400 - def test_post_courses_correct(self, client: FlaskClient, teacher: User): """Test posting a course""" response = client.post("/courses", headers = {"Authorization": "teacher"}, @@ -219,11 +241,6 @@ def test_post_courses_correct(self, client: FlaskClient, teacher: User): ### GET COURSE ### - def test_get_course_wrong_course_id(self, client: FlaskClient): - """Test getting a non existing course by giving a wrong course_id""" - response = client.get("/courses/0", headers = {"Authorization": "student"}) - assert response.status_code == 404 - def test_get_course_correct(self, client: FlaskClient, course: Course): """Test getting a course""" response = client.get( @@ -239,56 +256,6 @@ def test_get_course_correct(self, client: FlaskClient, course: Course): ### PATCH COURSE ### - def test_patch_course_wrong_course_id(self, client: FlaskClient): - """Test patching a course that does not exist""" - response = client.patch("/courses/0", headers = {"Authorization": "teacher"}) - assert response.status_code == 404 - - def test_patch_course_wrong_name_type(self, client: FlaskClient, course: Course): - """Test patching a course given a wrong type for the course name""" - response = client.patch( - f"/courses/{course.course_id}", - headers = {"Authorization": "teacher"}, - json = {"name": 0} - ) - assert response.status_code == 400 - - def test_patch_course_ufora_id_type(self, client: FlaskClient, course: Course): - """Test patching a course given a wrong type for the ufora_id""" - response = client.patch( - f"/courses/{course.course_id}", - headers = {"Authorization": "teacher"}, - json = {"ufora_id": 0} - ) - assert response.status_code == 400 - - def test_patch_course_wrong_teacher_type(self, client: FlaskClient, course: Course): - """Test patching a course given a wrong type for the teacher""" - response = client.patch( - f"/courses/{course.course_id}", - headers = {"Authorization": "teacher"}, - json = {"teacher": 0} - ) - assert response.status_code == 400 - - def test_patch_course_wrong_teacher(self, client: FlaskClient, course: Course): - """Test patching a course given a teacher that does not exist""" - response = client.patch( - f"/courses/{course.course_id}", - headers = {"Authorization": "teacher"}, - json = {"teacher": "no_teacher"} - ) - assert response.status_code == 400 - - def test_patch_course_incorrect_field(self, client: FlaskClient, course: Course): - """Test patching a course with a field that doesn't occur in the course model""" - response = client.patch( - f"/courses/{course.course_id}", - headers = {"Authorization": "teacher"}, - json = {"incorrect": 0} - ) - assert response.status_code == 400 - def test_patch_course_correct(self, client: FlaskClient, course: Course): """Test patching a course""" response = client.patch( @@ -302,14 +269,6 @@ def test_patch_course_correct(self, client: FlaskClient, course: Course): ### DELETE COURSE ### - def test_delete_course_wrong_course_id(self, client: FlaskClient): - """Test deleting a course that does not exist""" - response = client.delete( - "/courses/0", - headers = {"Authorization": "teacher"} - ) - assert response.status_code == 404 - def test_delete_course_correct(self, client: FlaskClient, course: Course): """Test deleting a course""" response = client.delete( @@ -326,11 +285,6 @@ def test_delete_course_correct(self, client: FlaskClient, course: Course): ### GET COURSE STUDENTS ### - def test_get_students_wrong_course_id(self, client: FlaskClient): - """Test getting the students of a non existing course by giving a wrong course_id""" - response = client.get("/courses/0/students", headers = {"Authorization": "student"}) - assert response.status_code == 404 - def test_get_students_correct(self, client: FlaskClient, api_host: str, course: Course): """Test getting the students fo a course""" response = client.get( @@ -343,48 +297,6 @@ def test_get_students_correct(self, client: FlaskClient, api_host: str, course: ### POST COURSE STUDENTS ### - def test_post_students_wrong_course_id(self, client: FlaskClient): - """Test adding students to a non existing course""" - response = client.post("/courses/0/students", headers = {"Authorization": "teacher"}) - assert response.status_code == 404 - - def test_post_students_wrong_students_type( - self, client: FlaskClient, course: Course, student_other: User - ): - """Test adding a student without putting it in a list""" - response = client.post( - f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, - json = { - "students": student_other.uid - } - ) - assert response.status_code == 400 - - def test_post_students_wrong_students(self, client: FlaskClient, course: Course): - """Test adding students with invalid uid values in the list""" - response = client.post( - f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, - json = { - "students": [None, "no_user"] - } - ) - assert response.status_code == 400 - - def test_post_students_incorrect_field( - self, client: FlaskClient, course: Course, student_other: User - ): - """Test adding students but give unnecessary fields to the data""" - response = client.post( - f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, - json = { - "incorrect": [student_other.uid] - } - ) - assert response.status_code == 400 - def test_post_students_correct( self, client: FlaskClient, api_host: str, course: Course, student_other: User ): @@ -402,48 +314,6 @@ def test_post_students_correct( ### DELETE COURSE STUDENTS ### - def test_delete_students_wrong_course_id(self, client: FlaskClient): - """Test deleting students from a non existing course""" - response = client.delete("/courses/0/students", headers = {"Authorization": "teacher"}) - assert response.status_code == 404 - - def test_delete_students_wrong_students_type( - self, client: FlaskClient, course: Course, student_other: User - ): - """Test deleting a student without putting it in a list""" - response = client.delete( - f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, - json = { - "students": student_other.uid - } - ) - assert response.status_code == 400 - - def test_delete_students_wrong_students(self, client: FlaskClient, course: Course): - """Test deleting students with invalid uid values in the list""" - response = client.delete( - f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, - json = { - "students": [None, "no_user"] - } - ) - assert response.status_code == 400 - - def test_delete_students_incorrect_field( - self, client: FlaskClient, course: Course, student: User - ): - """Test deleting students with an extra field that should not be there""" - response = client.delete( - f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, - json = { - "incorrect": [student.uid] - } - ) - assert response.status_code == 400 - def test_delete_students_correct( self, client: FlaskClient, course: Course, student: User ): @@ -466,11 +336,6 @@ def test_delete_students_correct( ### GET COURSE ADMINS ### - def test_get_admins_wrong_course_id(self, client: FlaskClient): - """Test getting the admins of a non existing course""" - response = client.get("/courses/0/admins", headers = {"Authorization": "teacher"}) - assert response.status_code == 404 - def test_get_admins_correct(self, client: FlaskClient, api_host: str, course: Course): """Test getting the admins of a course""" response = client.get( @@ -483,44 +348,6 @@ def test_get_admins_correct(self, client: FlaskClient, api_host: str, course: Co ### POST COURSE ADMINS ### - def test_post_admins_wrong_course_id(self, client: FlaskClient): - """Test adding admins to a non existing course""" - response = client.post("/courses/0/admins", headers = {"Authorization": "teacher"}) - assert response.status_code == 404 - - def test_post_admins_wrong_admin_uid_type(self, client: FlaskClient, course: Course): - """Test adding an admin where the uid has a wrong typing""" - response = client.post( - f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, - json = { - "admin_uid": None - } - ) - assert response.status_code == 400 - - def test_post_admins_wrong_user(self, client: FlaskClient, course: Course, student: User): - """Test adding a student as an admin""" - response = client.post( - f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, - json = { - "admin_uid": student.uid - } - ) - assert response.status_code == 400 - - def test_post_admins_incorrect_field(self, client: FlaskClient, course: Course, admin: User): - """Test adding an admin but the data has an incorrect field""" - response = client.post( - f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, - json = { - "incorrect": admin.uid - } - ) - assert response.status_code == 400 - def test_post_admins_correct(self, client: FlaskClient, course: Course, admin: User): """Test adding an admin to a course""" response = client.post( @@ -536,44 +363,6 @@ def test_post_admins_correct(self, client: FlaskClient, course: Course, admin: U ### DELETE COURSE ADMINS ### - def test_delete_admins_wrong_course_id(self, client: FlaskClient): - """Test deleting an admin from a non existing course""" - response = client.delete("/courses/0/admins", headers = {"Authorization": "teacher"}) - assert response.status_code == 404 - - def test_delete_admins_wrong_admin_uid_type(self, client: FlaskClient, course: Course): - """Test deleting an admin where the uid has the wrong typing""" - response = client.delete( - f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, - json = { - "admin_uid": None - } - ) - assert response.status_code == 400 - - def test_delete_admins_wrong_user(self, client: FlaskClient, course: Course, student: User): - """Test deleting an user that is not an admin for this course""" - response = client.delete( - f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, - json = { - "admin_uid": student.uid - } - ) - assert response.status_code == 400 - - def test_delete_admins_incorrect_field(self, client: FlaskClient, course: Course, admin: User): - """Test deleting an admin but the data has an incorrect field""" - response = client.delete( - f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, - json = { - "incorrect": admin.uid - } - ) - assert response.status_code == 400 - def test_delete_admins_correct(self, client: FlaskClient, course: Course, admin: User): """Test deleting an admin from a course""" response = client.delete( diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index 0b63d1bf..caacac95 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -1,43 +1,74 @@ """Base class for endpoint tests""" -from typing import List, Tuple +from typing import List, Tuple, Dict from pytest import param -def authentication_tests(tests: List[Tuple[str, List[str], List[str]]]) -> List[any]: +def authentication_tests(tests: List[Tuple[str, List[str]]]) -> List[any]: """Transform the format to single authentication tests""" single_tests = [] for test in tests: - endpoint, parameters, methods = test + endpoint, methods = test for method in methods: single_tests.append(param( - (endpoint, parameters, method), + (endpoint, method), id = f"{endpoint} {method.upper()}" )) return single_tests -def authorization_tests(tests: List[Tuple[str, List[str], str, List[str], List[str]]]) -> List[any]: +def authorization_tests(tests: List[Tuple[str, str, List[str], List[str]]]) -> List[any]: """Transform the format to single authorization tests""" single_tests = [] for test in tests: - endpoint, parameters, method, allowed_tokens, disallowed_tokens = test + endpoint, method, allowed_tokens, disallowed_tokens = test for token in (allowed_tokens + disallowed_tokens): allowed = token in allowed_tokens single_tests.append(param( - (endpoint, parameters, method, token, allowed), + (endpoint, method, token, allowed), id = f"{endpoint} {method.upper()} " \ f"({token} {'allowed' if allowed else 'disallowed'})" )) return single_tests +def data_field_tests( + tests: List[Tuple[str, str, str, Dict[str, any], Dict[str, List[Tuple[any, int]]]]] + ) -> List[any]: + """Transform the format to single data_field tests""" + + single_tests = [] + for test in tests: + endpoint, method, token, data, changes = test + + # Test by adding an incorrect field + new_data = dict(data) + new_data["field"] = None + single_tests.append(param( + (endpoint, method, token, new_data, 400), + id = f"{endpoint} {method.upper()} {token} (field None 400)" + )) + + # Test the with the given changes + for key, values in changes.items(): + for value, status in values: + new_data = dict(data) + new_data[key] = value + single_tests.append(param( + (endpoint, method, token, new_data, status), + id = f"{endpoint} {method.upper()} {token} ({key} {value} {status})" + )) + return single_tests + +def url_query_tests(tests: List[Tuple[str]]): + """Transform the format to single url_query tests""" + class TestEndpoint: """Base class for endpoint tests""" - def authentication(self, authentication_parameter: Tuple[str, any]): + def authentication(self, auth_test: Tuple[str, any]): """Test if the authentication for the given enpoint works""" - endpoint, method = authentication_parameter + endpoint, method = auth_test response = method(endpoint) assert response.status_code == 401 @@ -48,10 +79,21 @@ def authentication(self, authentication_parameter: Tuple[str, any]): response = method(endpoint, headers = {"Authorization": "login"}) assert response.status_code != 401 - def authorization(self, auth_parameter: Tuple[str, any, str, bool]): + def authorization(self, auth_test: Tuple[str, any, str, bool]): """Test if the authorization for the given endpoint works""" - endpoint, method, token, allowed = auth_parameter + endpoint, method, token, allowed = auth_test response = method(endpoint, headers = {"Authorization": token}) assert allowed == (response.status_code != 403) + + def data_field(self, data_field_test: Tuple[str, any, str, Dict[str, any], int]): + """Test if a data field for the given endpoint works""" + + endpoint, method, token, data, status = data_field_test + + response = method(endpoint, headers = {"Authorization": token}, json = data) + assert response.status_code == status + + def url_query(self): + """Test if url query for the given endpoint works""" From 515e1771e7dba512e416da0291e99d4bdbcdd469 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 9 Apr 2024 17:56:32 +0200 Subject: [PATCH 257/377] Cleanup + renaming --- backend/tests/endpoints/conftest.py | 9 ++- .../tests/endpoints/course/courses_test.py | 64 ++++++++----------- backend/tests/endpoints/endpoint.py | 26 +++----- 3 files changed, 39 insertions(+), 60 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 5799ddee..703d9cd3 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -37,12 +37,11 @@ def auth_test(request: FixtureRequest, client: FlaskClient, data_map: Dict[str, return endpoint, getattr(client, method), *other @fixture -def data_field_test( +def data_field_type_test( request: FixtureRequest, client: FlaskClient, data_map: Dict[str, any] - ) -> Tuple[str, any, str, Dict[str, any], int]: + ) -> Tuple[str, any, str, Dict[str, any]]: """Add concrete test data to the data_field tests""" - # endpoint, method, token, data, status - endpoint, method, token, data, status = request.param + endpoint, method, token, data = request.param for key, value in data_map.items(): endpoint = endpoint.replace(key, str(value)) @@ -53,7 +52,7 @@ def data_field_test( elif value in data_map.keys(): data[key] = data_map[value] - return endpoint, getattr(client, method), token, data, status + return endpoint, getattr(client, method), token, data diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 33df849b..1fd1506a 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -7,7 +7,7 @@ TestEndpoint, authentication_tests, authorization_tests, - data_field_tests + data_field_type_tests ) from project.models.user import User from project.models.course import Course @@ -63,51 +63,37 @@ def test_authorization(self, auth_test: Tuple[str, any, str, bool]): - ### DATA & QUERIES ### - # Test a data field - # Other tests to verify the changes for a correct request - # (endpoint, method, token, minimal_data, {key, [(value, status)]}) - data_fields = data_field_tests([ + ### DATA ### + # Test a data field by passing a list of values for which it should return bad request + # (endpoint, method, token, minimal_data, {key, [value]}) + data_fields = data_field_type_tests([ ("/courses", "post", "teacher", {"name": "test", "ufora_id": "test"}, { - "name": [(None, 400), (0, 400)], - "ufora_id": [(0, 400)], + "name": [None, 0], + "ufora_id": [0], }), ("/courses/@course_id", "patch", "teacher", {}, { - "name": [(None, 400), (0, 400)], - "ufora_id": [(0, 400)], - "teacher": [(None, 400), (0, 400), ("student", 400)], + "name": [None, 0], + "ufora_id": [0], + "teacher": [None, 0, "student"], }), - ("/courses/@course_id/students", "post", "teacher", - {"students": ["student_other"]}, - {"students": [(None, 400), ([None], 400), (["no_user"], 400), (["student"], 400)]} - ), - ("/courses/@course_id/students", "delete", "teacher", - {"students": ["student"]}, - {"students": [ - (None, 400), ([None], 400), (["no_user"], 400), (["student_other"], 400) - ]} - ), - ("/courses/@course_id/admins", "post", "teacher", - {"admin_uid": "admin_other"}, - {"admin_uid": [(None, 400), ("no_user", 400), ("student", 400), ("admin", 400)]} - ), - ("/courses/@course_id/admins", "delete", "teacher", - {"admin_uid": ["admin"]}, - {"admin_uid": [(None, 400), ("no_user", 400), ("admin_other", 400)]} - ) + ("/courses/@course_id/students", "post", "teacher", {"students": ["student_other"]}, { + "students": [None, [None], ["no_user"], ["student"]] + }), + ("/courses/@course_id/students", "delete", "teacher", {"students": ["student"]}, { + "students": [None, [None], ["no_user"], ["student_other"]] + }), + ("/courses/@course_id/admins", "post", "teacher", {"admin_uid": "admin_other"}, { + "admin_uid": [None, "no_user", "student", "admin"] + }), + ("/courses/@course_id/admins", "delete", "teacher", {"admin_uid": ["admin"]}, { + "admin_uid": [None, "no_user", "admin_other"] + }) ]) - # queries = [] - - @mark.parametrize("data_field_test", data_fields, indirect=True) - def test_data_fields(self, data_field_test: Tuple[str, any, str, Dict[str, any], int]): + @mark.parametrize("data_field_type_test", data_fields, indirect=True) + def test_data_fields(self, data_field_type_test: Tuple[str, any, str, Dict[str, any]]): """Test a data field""" - super().data_field(data_field_test) - - # @mark.parametrize("url_query", queries) - # def test_url_query(self, url_query: Tuple[str]): - # """Test a url query""" - # super().url_query(url_query) + super().data_field_type(data_field_type_test) diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index caacac95..f04195f6 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -31,8 +31,8 @@ def authorization_tests(tests: List[Tuple[str, str, List[str], List[str]]]) -> L )) return single_tests -def data_field_tests( - tests: List[Tuple[str, str, str, Dict[str, any], Dict[str, List[Tuple[any, int]]]]] +def data_field_type_tests( + tests: List[Tuple[str, str, str, Dict[str, any], Dict[str, List[any]]]] ) -> List[any]: """Transform the format to single data_field tests""" @@ -44,24 +44,21 @@ def data_field_tests( new_data = dict(data) new_data["field"] = None single_tests.append(param( - (endpoint, method, token, new_data, 400), + (endpoint, method, token, new_data), id = f"{endpoint} {method.upper()} {token} (field None 400)" )) # Test the with the given changes for key, values in changes.items(): - for value, status in values: + for value in values: new_data = dict(data) new_data[key] = value single_tests.append(param( - (endpoint, method, token, new_data, status), - id = f"{endpoint} {method.upper()} {token} ({key} {value} {status})" + (endpoint, method, token, new_data), + id = f"{endpoint} {method.upper()} {token} ({key} {value} 400)" )) return single_tests -def url_query_tests(tests: List[Tuple[str]]): - """Transform the format to single url_query tests""" - class TestEndpoint: """Base class for endpoint tests""" @@ -87,13 +84,10 @@ def authorization(self, auth_test: Tuple[str, any, str, bool]): response = method(endpoint, headers = {"Authorization": token}) assert allowed == (response.status_code != 403) - def data_field(self, data_field_test: Tuple[str, any, str, Dict[str, any], int]): - """Test if a data field for the given endpoint works""" + def data_field_type(self, data_field_test: Tuple[str, any, str, Dict[str, any]]): + """Test if the datatypes are properly checked for data fields""" - endpoint, method, token, data, status = data_field_test + endpoint, method, token, data = data_field_test response = method(endpoint, headers = {"Authorization": token}, json = data) - assert response.status_code == status - - def url_query(self): - """Test if url query for the given endpoint works""" + assert response.status_code == 400 From 5aead7fa51d3719471fe3e171f0503d3a43e24ee Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 9 Apr 2024 19:47:21 +0200 Subject: [PATCH 258/377] Moving format to functions to make it clearer --- .../tests/endpoints/course/courses_test.py | 112 +++++++++--------- backend/tests/endpoints/endpoint.py | 87 +++++++------- 2 files changed, 95 insertions(+), 104 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 1fd1506a..b8da2972 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -17,46 +17,39 @@ class TestCourseEndpoint(TestEndpoint): ### AUTHENTICATION & AUTHORIZATION ### # Where is login required - # (endpoint, methods) - authentication = authentication_tests([ - ("/courses", ["get", "post"]), - ("/courses/@course_id", ["get", "patch", "delete"]), - ("/courses/@course_id/students", ["get", "post", "delete"]), - ("/courses/@course_id/admins", ["get", "post", "delete"]) - ]) + authentication_tests = \ + authentication_tests("/courses", ["get", "post"]) + \ + authentication_tests("/courses/@course_id", ["get", "patch", "delete"]) + \ + authentication_tests("/courses/@course_id/students", ["get", "post", "delete"]) + \ + authentication_tests("/courses/@course_id/admins", ["get", "post", "delete"]) # Who can access what - # (endpoint, method, allowed, disallowed) - authorization = authorization_tests([ - ("/courses", "get", ["student", "teacher", "admin"], []), - ("/courses", "post", ["teacher"], ["student", "admin"]), - - ("/courses/@course_id", "patch", - ["teacher"], ["student", "teacher_other", "admin"]), - ("/courses/@course_id", "delete", - ["teacher"], ["student", "teacher_other", "admin"]), - - ("/courses/@course_id/students", "get", - ["student", "teacher", "admin"], []), - ("/courses/@course_id/students", "post", - ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), - ("/courses/@course_id/students", "delete", - ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), - - ("/courses/@course_id/admins", "get", - ["teacher", "admin"], ["student", "teacher_other", "admin_other"]), - ("/courses/@course_id/admins", "post", - ["teacher"], ["student", "admin"]), - ("/courses/@course_id/admins", "delete", - ["teacher"], ["student", "teacher_other", "admin"]), - ]) - - @mark.parametrize("auth_test", authentication, indirect=True) + authorization_tests = \ + authorization_tests("/courses", "get", ["student", "teacher", "admin"], []) + \ + authorization_tests("/courses", "post", ["teacher"], ["student", "admin"]) + \ + authorization_tests("/courses/@course_id", "patch", + ["teacher"], ["student", "teacher_other", "admin"]) + \ + authorization_tests("/courses/@course_id", "delete", + ["teacher"], ["student", "teacher_other", "admin"]) + \ + authorization_tests("/courses/@course_id/students", "get", + ["student", "teacher", "admin"], []) + \ + authorization_tests("/courses/@course_id/students", "post", + ["teacher", "admin"], ["student", "teacher_other", "admin_other"]) + \ + authorization_tests("/courses/@course_id/students", "delete", + ["teacher", "admin"], ["student", "teacher_other", "admin_other"]) + \ + authorization_tests("/courses/@course_id/admins", "get", + ["teacher", "admin"], ["student", "teacher_other", "admin_other"]) + \ + authorization_tests("/courses/@course_id/admins", "post", + ["teacher"], ["student", "admin"]) + \ + authorization_tests("/courses/@course_id/admins", "delete", + ["teacher"], ["student", "teacher_other", "admin"]) + + @mark.parametrize("auth_test", authentication_tests, indirect=True) def test_authentication(self, auth_test: Tuple[str, any]): """Test the authentication""" super().authentication(auth_test) - @mark.parametrize("auth_test", authorization, indirect=True) + @mark.parametrize("auth_test", authorization_tests, indirect=True) def test_authorization(self, auth_test: Tuple[str, any, str, bool]): """Test the authorization""" super().authorization(auth_test) @@ -65,34 +58,35 @@ def test_authorization(self, auth_test: Tuple[str, any, str, bool]): ### DATA ### # Test a data field by passing a list of values for which it should return bad request - # (endpoint, method, token, minimal_data, {key, [value]}) - data_fields = data_field_type_tests([ - ("/courses", "post", "teacher", {"name": "test", "ufora_id": "test"}, { - "name": [None, 0], - "ufora_id": [0], - }), - ("/courses/@course_id", "patch", "teacher", {}, { - "name": [None, 0], - "ufora_id": [0], - "teacher": [None, 0, "student"], - }), - ("/courses/@course_id/students", "post", "teacher", {"students": ["student_other"]}, { - "students": [None, [None], ["no_user"], ["student"]] - }), - ("/courses/@course_id/students", "delete", "teacher", {"students": ["student"]}, { - "students": [None, [None], ["no_user"], ["student_other"]] - }), - ("/courses/@course_id/admins", "post", "teacher", {"admin_uid": "admin_other"}, { - "admin_uid": [None, "no_user", "student", "admin"] - }), - ("/courses/@course_id/admins", "delete", "teacher", {"admin_uid": ["admin"]}, { - "admin_uid": [None, "no_user", "admin_other"] - }) - ]) + data_fields = \ + data_field_type_tests("/courses", "post", "teacher", + {"name": "test", "ufora_id": "test"}, + {"name": [None, 0], "ufora_id": [0]} + ) + \ + data_field_type_tests("/courses/@course_id", "patch", "teacher", + {}, + {"name": [None, 0], "ufora_id": [0], "teacher": [None, 0, "student"]} + ) + \ + data_field_type_tests("/courses/@course_id/students", "post", "teacher", + {"students": ["student_other"]}, + {"students": [None, [None], ["no_user"], ["student"]]} + ) + \ + data_field_type_tests("/courses/@course_id/students", "delete", "teacher", + {"students": ["student"]}, + {"students": [None, [None], ["no_user"], ["student_other"]]} + ) + \ + data_field_type_tests("/courses/@course_id/admins", "post", "teacher", + {"admin_uid": "admin_other"}, + {"admin_uid": [None, "no_user", "student", "admin"]} + ) + \ + data_field_type_tests("/courses/@course_id/admins", "delete", "teacher", + {"admin_uid": ["admin"]}, + {"admin_uid": [None, "no_user", "admin_other"]} + ) @mark.parametrize("data_field_type_test", data_fields, indirect=True) def test_data_fields(self, data_field_type_test: Tuple[str, any, str, Dict[str, any]]): - """Test a data field""" + """Test a data field typing""" super().data_field_type(data_field_type_test) diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index f04195f6..2002f719 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -3,61 +3,58 @@ from typing import List, Tuple, Dict from pytest import param -def authentication_tests(tests: List[Tuple[str, List[str]]]) -> List[any]: +def authentication_tests(endpoint: str, methods: List[str]) -> List[any]: """Transform the format to single authentication tests""" + tests = [] - single_tests = [] - for test in tests: - endpoint, methods = test - for method in methods: - single_tests.append(param( - (endpoint, method), - id = f"{endpoint} {method.upper()}" - )) - return single_tests + for method in methods: + tests.append(param( + (endpoint, method), + id = f"{endpoint} {method.upper()}" + )) + + return tests -def authorization_tests(tests: List[Tuple[str, str, List[str], List[str]]]) -> List[any]: +def authorization_tests( + endpoint: str, method: str, allowed_tokens: List[str], disallowed_tokens: List[str] + ) -> List[any]: """Transform the format to single authorization tests""" + tests = [] - single_tests = [] - for test in tests: - endpoint, method, allowed_tokens, disallowed_tokens = test - for token in (allowed_tokens + disallowed_tokens): - allowed = token in allowed_tokens - single_tests.append(param( - (endpoint, method, token, allowed), - id = f"{endpoint} {method.upper()} " \ - f"({token} {'allowed' if allowed else 'disallowed'})" - )) - return single_tests + for token in (allowed_tokens + disallowed_tokens): + allowed = token in allowed_tokens + tests.append(param( + (endpoint, method, token, allowed), + id = f"{endpoint} {method.upper()} ({token} {'allowed' if allowed else 'disallowed'})" + )) + + return tests def data_field_type_tests( - tests: List[Tuple[str, str, str, Dict[str, any], Dict[str, List[any]]]] + endpoint: str, method: str, token: str, data: Dict[str, any], changes: Dict[str, List[any]] ) -> List[any]: """Transform the format to single data_field tests""" + tests = [] + + # Test by adding an incorrect field + new_data = dict(data) + new_data["field"] = None + tests.append(param( + (endpoint, method, token, new_data), + id = f"{endpoint} {method.upper()} {token} (field None 400)" + )) + + # Test the with the given changes + for key, values in changes.items(): + for value in values: + new_data = dict(data) + new_data[key] = value + tests.append(param( + (endpoint, method, token, new_data), + id = f"{endpoint} {method.upper()} {token} ({key} {value} 400)" + )) - single_tests = [] - for test in tests: - endpoint, method, token, data, changes = test - - # Test by adding an incorrect field - new_data = dict(data) - new_data["field"] = None - single_tests.append(param( - (endpoint, method, token, new_data), - id = f"{endpoint} {method.upper()} {token} (field None 400)" - )) - - # Test the with the given changes - for key, values in changes.items(): - for value in values: - new_data = dict(data) - new_data[key] = value - single_tests.append(param( - (endpoint, method, token, new_data), - id = f"{endpoint} {method.upper()} {token} ({key} {value} 400)" - )) - return single_tests + return tests class TestEndpoint: """Base class for endpoint tests""" From 4cdb3856323804b384cc4c82a102fcfea600e7c7 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 9 Apr 2024 20:36:07 +0200 Subject: [PATCH 259/377] cleanup --- .../tests/endpoints/course/courses_test.py | 72 +++++++------------ backend/tests/endpoints/endpoint.py | 24 +++---- 2 files changed, 39 insertions(+), 57 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index b8da2972..a8106cf3 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,6 +1,6 @@ """Tests the courses API endpoint""" -from typing import Tuple, List, Dict +from typing import Any from pytest import mark from flask.testing import FlaskClient from tests.endpoints.endpoint import ( @@ -15,7 +15,7 @@ class TestCourseEndpoint(TestEndpoint): """Class to test the courses API endpoint""" - ### AUTHENTICATION & AUTHORIZATION ### + ### AUTHENTICATION ### # Where is login required authentication_tests = \ authentication_tests("/courses", ["get", "post"]) + \ @@ -23,6 +23,14 @@ class TestCourseEndpoint(TestEndpoint): authentication_tests("/courses/@course_id/students", ["get", "post", "delete"]) + \ authentication_tests("/courses/@course_id/admins", ["get", "post", "delete"]) + @mark.parametrize("auth_test", authentication_tests, indirect=True) + def test_authentication(self, auth_test: tuple[str, Any]): + """Test the authentication""" + super().authentication(auth_test) + + + + ### AUTHORIZATION ### # Who can access what authorization_tests = \ authorization_tests("/courses", "get", ["student", "teacher", "admin"], []) + \ @@ -44,13 +52,8 @@ class TestCourseEndpoint(TestEndpoint): authorization_tests("/courses/@course_id/admins", "delete", ["teacher"], ["student", "teacher_other", "admin"]) - @mark.parametrize("auth_test", authentication_tests, indirect=True) - def test_authentication(self, auth_test: Tuple[str, any]): - """Test the authentication""" - super().authentication(auth_test) - @mark.parametrize("auth_test", authorization_tests, indirect=True) - def test_authorization(self, auth_test: Tuple[str, any, str, bool]): + def test_authorization(self, auth_test: tuple[str, Any, str, bool]): """Test the authorization""" super().authorization(auth_test) @@ -85,14 +88,14 @@ def test_authorization(self, auth_test: Tuple[str, any, str, bool]): ) @mark.parametrize("data_field_type_test", data_fields, indirect=True) - def test_data_fields(self, data_field_type_test: Tuple[str, any, str, Dict[str, any]]): + def test_data_fields(self, data_field_type_test: tuple[str, Any, str, dict[str, Any]]): """Test a data field typing""" super().data_field_type(data_field_type_test) - ### GET COURSES ### - def test_get_courses_all(self, client: FlaskClient, courses: List[Course]): + ### COURSES ### + def test_get_courses(self, client: FlaskClient, courses: list[Course]): """Test getting all courses""" response = client.get("/courses", headers = {"Authorization": "student"}) assert response.status_code == 200 @@ -200,10 +203,7 @@ def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, course: Co assert data["ufora_id"] == course.ufora_id assert data["teacher"] == course.teacher - - - ### POST COURSES ### - def test_post_courses_correct(self, client: FlaskClient, teacher: User): + def test_post_courses(self, client: FlaskClient, teacher: User): """Test posting a course""" response = client.post("/courses", headers = {"Authorization": "teacher"}, json = { @@ -220,8 +220,8 @@ def test_post_courses_correct(self, client: FlaskClient, teacher: User): - ### GET COURSE ### - def test_get_course_correct(self, client: FlaskClient, course: Course): + ### COURSE ### + def test_get_course(self, client: FlaskClient, course: Course): """Test getting a course""" response = client.get( f"/courses/{course.course_id}", @@ -233,10 +233,7 @@ def test_get_course_correct(self, client: FlaskClient, course: Course): assert data["ufora_id"] == course.ufora_id assert data["teacher"] == course.teacher - - - ### PATCH COURSE ### - def test_patch_course_correct(self, client: FlaskClient, course: Course): + def test_patch_course(self, client: FlaskClient, course: Course): """Test patching a course""" response = client.patch( f"/courses/{course.course_id}", @@ -246,10 +243,7 @@ def test_patch_course_correct(self, client: FlaskClient, course: Course): assert response.status_code == 200 assert response.json["data"]["name"] == "test" - - - ### DELETE COURSE ### - def test_delete_course_correct(self, client: FlaskClient, course: Course): + def test_delete_course(self, client: FlaskClient, course: Course): """Test deleting a course""" response = client.delete( f"/courses/{course.course_id}", @@ -264,8 +258,8 @@ def test_delete_course_correct(self, client: FlaskClient, course: Course): - ### GET COURSE STUDENTS ### - def test_get_students_correct(self, client: FlaskClient, api_host: str, course: Course): + ### COURSE STUDENTS ### + def test_get_students(self, client: FlaskClient, api_host: str, course: Course): """Test getting the students fo a course""" response = client.get( f"/courses/{course.course_id}/students", @@ -274,10 +268,7 @@ def test_get_students_correct(self, client: FlaskClient, api_host: str, course: assert response.status_code == 200 assert response.json["data"][0]["uid"] == f"{api_host}/users/student" - - - ### POST COURSE STUDENTS ### - def test_post_students_correct( + def test_post_students( self, client: FlaskClient, api_host: str, course: Course, student_other: User ): """Test adding students to a course""" @@ -291,10 +282,7 @@ def test_post_students_correct( assert response.status_code == 201 assert response.json["data"]["students"][0] == f"{api_host}/users/student_other" - - - ### DELETE COURSE STUDENTS ### - def test_delete_students_correct( + def test_delete_students( self, client: FlaskClient, course: Course, student: User ): """Test deleting students from a course""" @@ -315,8 +303,8 @@ def test_delete_students_correct( - ### GET COURSE ADMINS ### - def test_get_admins_correct(self, client: FlaskClient, api_host: str, course: Course): + ### COURSE ADMINS ### + def test_get_admins(self, client: FlaskClient, api_host: str, course: Course): """Test getting the admins of a course""" response = client.get( f"/courses/{course.course_id}/admins", @@ -325,10 +313,7 @@ def test_get_admins_correct(self, client: FlaskClient, api_host: str, course: Co assert response.status_code == 200 assert response.json["data"][0]["uid"] == f"{api_host}/users/admin" - - - ### POST COURSE ADMINS ### - def test_post_admins_correct(self, client: FlaskClient, course: Course, admin: User): + def test_post_admins(self, client: FlaskClient, course: Course, admin: User): """Test adding an admin to a course""" response = client.post( f"/courses/{course.course_id}/admins", @@ -340,10 +325,7 @@ def test_post_admins_correct(self, client: FlaskClient, course: Course, admin: U assert response.status_code == 201 assert response.json["data"]["uid"] == admin.uid - - - ### DELETE COURSE ADMINS ### - def test_delete_admins_correct(self, client: FlaskClient, course: Course, admin: User): + def test_delete_admins(self, client: FlaskClient, course: Course, admin: User): """Test deleting an admin from a course""" response = client.delete( f"/courses/{course.course_id}/admins", diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index 2002f719..8b8f6b9a 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -1,9 +1,9 @@ """Base class for endpoint tests""" -from typing import List, Tuple, Dict +from typing import Any from pytest import param -def authentication_tests(endpoint: str, methods: List[str]) -> List[any]: +def authentication_tests(endpoint: str, methods: list[str]) -> list[Any]: """Transform the format to single authentication tests""" tests = [] @@ -16,13 +16,13 @@ def authentication_tests(endpoint: str, methods: List[str]) -> List[any]: return tests def authorization_tests( - endpoint: str, method: str, allowed_tokens: List[str], disallowed_tokens: List[str] - ) -> List[any]: + endpoint: str, method: str, allowed_tokens: list[str], disallowed_tokens: list[str] + ) -> list[Any]: """Transform the format to single authorization tests""" tests = [] for token in (allowed_tokens + disallowed_tokens): - allowed = token in allowed_tokens + allowed: bool = token in allowed_tokens tests.append(param( (endpoint, method, token, allowed), id = f"{endpoint} {method.upper()} ({token} {'allowed' if allowed else 'disallowed'})" @@ -31,9 +31,9 @@ def authorization_tests( return tests def data_field_type_tests( - endpoint: str, method: str, token: str, data: Dict[str, any], changes: Dict[str, List[any]] - ) -> List[any]: - """Transform the format to single data_field tests""" + endpoint: str, method: str, token: str, data: dict[str, Any], changes: dict[str, list[Any]] + ) -> list[Any]: + """Transform the format to single data_field_type tests""" tests = [] # Test by adding an incorrect field @@ -59,7 +59,7 @@ def data_field_type_tests( class TestEndpoint: """Base class for endpoint tests""" - def authentication(self, auth_test: Tuple[str, any]): + def authentication(self, auth_test: tuple[str, Any]): """Test if the authentication for the given enpoint works""" endpoint, method = auth_test @@ -73,7 +73,7 @@ def authentication(self, auth_test: Tuple[str, any]): response = method(endpoint, headers = {"Authorization": "login"}) assert response.status_code != 401 - def authorization(self, auth_test: Tuple[str, any, str, bool]): + def authorization(self, auth_test: tuple[str, Any, str, bool]): """Test if the authorization for the given endpoint works""" endpoint, method, token, allowed = auth_test @@ -81,10 +81,10 @@ def authorization(self, auth_test: Tuple[str, any, str, bool]): response = method(endpoint, headers = {"Authorization": token}) assert allowed == (response.status_code != 403) - def data_field_type(self, data_field_test: Tuple[str, any, str, Dict[str, any]]): + def data_field_type(self, test: tuple[str, Any, str, dict[str, Any]]): """Test if the datatypes are properly checked for data fields""" - endpoint, method, token, data = data_field_test + endpoint, method, token, data = test response = method(endpoint, headers = {"Authorization": token}, json = data) assert response.status_code == 400 From 8f346319f97bb319dec5988b65260114535fc109 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 9 Apr 2024 21:36:56 +0200 Subject: [PATCH 260/377] Fix test_post_admins --- backend/project/models/course_relation.py | 6 ++++-- backend/tests/endpoints/conftest.py | 5 +++++ backend/tests/endpoints/course/courses_test.py | 6 +++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/backend/project/models/course_relation.py b/backend/project/models/course_relation.py index 7c27af31..e4313ab1 100644 --- a/backend/project/models/course_relation.py +++ b/backend/project/models/course_relation.py @@ -1,8 +1,10 @@ """Course relation model""" +from dataclasses import dataclass from sqlalchemy import Integer, Column, ForeignKey, String from project.db_in import db +@dataclass class BaseCourseRelation(db.Model): """Base class for course relation models, both course relation tables have a @@ -11,8 +13,8 @@ class BaseCourseRelation(db.Model): __abstract__ = True - course_id = Column(Integer, ForeignKey('courses.course_id'), primary_key=True) - uid = Column(String(255), ForeignKey("users.uid"), primary_key=True) + course_id: int = Column(Integer, ForeignKey('courses.course_id'), primary_key=True) + uid: str = Column(String(255), ForeignKey("users.uid"), primary_key=True) class CourseAdmin(BaseCourseRelation): """Admin to course relation model""" diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 703d9cd3..c4cc7d69 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -77,6 +77,11 @@ def admin(session: Session) -> User: """Return an admin entry""" return session.get(User, "admin") +@fixture +def admin_other(session: Session) -> User: + """Return an admin entry""" + return session.get(User, "admin_other") + ### COURSES ### diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index a8106cf3..35ea32b5 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -313,17 +313,17 @@ def test_get_admins(self, client: FlaskClient, api_host: str, course: Course): assert response.status_code == 200 assert response.json["data"][0]["uid"] == f"{api_host}/users/admin" - def test_post_admins(self, client: FlaskClient, course: Course, admin: User): + def test_post_admins(self, client: FlaskClient, course: Course, admin_other: User): """Test adding an admin to a course""" response = client.post( f"/courses/{course.course_id}/admins", headers = {"Authorization": "teacher"}, json = { - "admin_uid": admin.uid + "admin_uid": admin_other.uid } ) assert response.status_code == 201 - assert response.json["data"]["uid"] == admin.uid + assert response.json["data"]["uid"] == admin_other.uid def test_delete_admins(self, client: FlaskClient, course: Course, admin: User): """Test deleting an admin from a course""" From f9e1f15a68b3c7bcac7acaccf3f821f5970df4f7 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 9 Apr 2024 21:49:16 +0200 Subject: [PATCH 261/377] Fix test_get_courses_wrong_parameter --- backend/project/utils/misc.py | 4 ++++ backend/project/utils/query_agent.py | 7 ++++--- backend/tests/endpoints/course/courses_test.py | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index 2995f8de..a836ff34 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -62,6 +62,10 @@ def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: return [model_to_dict(instance) for instance in instances] +def check_model_fields(model: DeclarativeMeta, data: dict[str, str]) -> bool: + """Checks if the data only contains fields of the model""" + return all(hasattr(model, key) for key in data.keys()) + def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): """ Filters the data to only contain the fields of the model. diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 01368eb3..a4e80795 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -12,7 +12,7 @@ from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError from project.db_in import db -from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields +from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields, check_model_fields def delete_by_id_from_model( model: DeclarativeMeta, @@ -141,9 +141,10 @@ def query_selected_from_model(model: DeclarativeMeta, try: query: Query = model.query if filters: - filtered_filters = filter_model_fields(model, filters) + if not check_model_fields(model, filters): + return {"message": "Unknown parameter", "url": response_url}, 400 conditions: List[bool] = [] - for key, value in filtered_filters.items(): + for key, value in filters.items(): conditions.append(getattr(model, key) == value) query = query.filter(and_(*conditions)) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 35ea32b5..d83c0c89 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -102,7 +102,7 @@ def test_get_courses(self, client: FlaskClient, courses: list[Course]): data = [course["name"] for course in response.json["data"]] assert all(course.name in data for course in courses) - def test_get_courses_wrong_argument(self, client: FlaskClient): + def test_get_courses_wrong_parameter(self, client: FlaskClient): """Test getting courses for a wrong parameter""" response = client.get("/courses?parameter=0", headers = {"Authorization": "student"}) assert response.status_code == 400 From 167dcc4cfd84f73caac0cf2eeb959aa1b5a69ca4 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Wed, 10 Apr 2024 11:59:43 +0200 Subject: [PATCH 262/377] Creates project display for students and solves issue 186 (#187) * init project display * Fix #175 * added dependencies for #178 * Fix #186 * fixed test * resolved linting * fixed download url of submissions * added title to header * fixed typos * merge fail --- backend/project/endpoints/submissions.py | 27 +- backend/project/utils/files.py | 14 - backend/tests/endpoints/submissions_test.py | 10 +- frontend/package-lock.json | 3701 +++++++++++------ frontend/package.json | 3 + frontend/public/locales/en/translation.json | 23 + frontend/public/locales/nl/translation.json | 23 + frontend/src/App.tsx | 4 + .../components/FolderUpload/FolderUpload.tsx | 2 +- .../pages/project/projectView/ProjectView.tsx | 115 + .../project/projectView/SubmissionCard.tsx | 150 + .../project/projectView/SubmissionsGrid.tsx | 92 + frontend/src/types/course.ts | 4 + frontend/src/types/submission.ts | 5 + frontend/src/utils/date-utils.ts | 27 + 15 files changed, 2906 insertions(+), 1294 deletions(-) create mode 100644 frontend/src/pages/project/projectView/ProjectView.tsx create mode 100644 frontend/src/pages/project/projectView/SubmissionCard.tsx create mode 100644 frontend/src/pages/project/projectView/SubmissionsGrid.tsx create mode 100644 frontend/src/types/course.ts create mode 100644 frontend/src/types/submission.ts create mode 100644 frontend/src/utils/date-utils.ts diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 9ce2db66..e4d204e7 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -12,7 +12,7 @@ from project.models.submission import Submission, SubmissionStatus from project.models.project import Project from project.models.user import User -from project.utils.files import filter_files, all_files_uploaded +from project.utils.files import all_files_uploaded from project.utils.user import is_valid_user from project.utils.project import is_valid_project from project.utils.query_agent import query_selected_from_model, delete_by_id_from_model @@ -112,11 +112,12 @@ def post(self) -> dict[str, any]: # Submission files submission.submission_path = "" # Must be set on creation - files = filter_files(request.files.getlist("files")) + files = request.files.getlist("files") # Check files otherwise stop project = session.get(Project, submission.project_id) - if not files or not all_files_uploaded(files, project.regex_expressions): + if project.regex_expressions and \ + (not files or not all_files_uploaded(files, project.regex_expressions)): data["message"] = "No files were uploaded" if not files else \ "Not all required files were uploaded " \ f"(required files={','.join(project.regex_expressions)})" @@ -141,12 +142,12 @@ def post(self) -> dict[str, any]: data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { - "id": urljoin(f"{BASE_URL}/", submission.submission_id), - "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), + "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), + "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, - "time": submission.submission_time, - "status": submission.submission_status + "submission_time": submission.submission_time, + "submission_status": submission.submission_status } return data, 201 @@ -182,12 +183,12 @@ def get(self, submission_id: int) -> dict[str, any]: data["message"] = "Successfully fetched the submission" data["data"] = { - "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), - "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), + "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), + "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), "grading": submission.grading, - "time": submission.submission_time, - "status": submission.submission_status + "submission_time": submission.submission_time, + "submission_status": submission.submission_status } return data, 200 diff --git a/backend/project/utils/files.py b/backend/project/utils/files.py index 3c8d381c..3b3c807c 100644 --- a/backend/project/utils/files.py +++ b/backend/project/utils/files.py @@ -1,6 +1,5 @@ """Utility functions for files""" -from os.path import getsize from re import match from typing import List, Optional from io import BytesIO @@ -8,19 +7,6 @@ from werkzeug.utils import secure_filename from werkzeug.datastructures import FileStorage -def filter_files(files: List[FileStorage]) -> List[FileStorage]: - """Filter out bad files - - Args: - files (List[FileStorage]): A list of files to filter on - - Returns: - List[FileStorage]: The filtered list - """ - return list(filter(lambda file: - file and file.filename != "" and getsize(file.filename) > 0, - files - )) def all_files_uploaded(files: List[FileStorage], regexes: List[str]) -> bool: """Check if all the required files are uploaded diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 1354b4be..e154575e 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -59,12 +59,12 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" assert data["data"] == { - "id": f"{API_HOST}/submissions/{submission.submission_id}", - "user": f"{API_HOST}/users/student01", - "project": f"{API_HOST}/projects/{project.project_id}", + "submission_id": f"{API_HOST}/submissions/{submission.submission_id}", + "uid": f"{API_HOST}/users/student01", + "project_id": f"{API_HOST}/projects/{project.project_id}", "grading": 16, - "time": "Thu, 14 Mar 2024 12:00:00 GMT", - "status": 'SUCCESS' + "submission_time": "Thu, 14 Mar 2024 12:00:00 GMT", + "submission_status": 'SUCCESS' } ### PATCH SUBMISSION ### diff --git a/frontend/package-lock.json b/frontend/package-lock.json index abde71c1..8c85efc2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,7 +13,9 @@ "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", + "@mui/x-data-grid": "^7.1.1", "@mui/x-date-pickers": "^7.1.1", + "axios": "^1.6.8", "dayjs": "^1.11.10", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", @@ -21,6 +23,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", + "react-markdown": "^9.0.1", "react-router-dom": "^6.22.1", "stream": "^0.0.2", "styled-components": "^6.1.8" @@ -54,19 +57,6 @@ "node": ">=0.10.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", @@ -88,91 +78,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/core": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", - "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.4", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", - "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.24.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", @@ -195,6 +100,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-function-name/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-hoist-variables": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", @@ -207,6 +126,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-hoist-variables/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.24.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", @@ -218,23 +151,17 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", - "dev": true, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-plugin-utils": { @@ -258,6 +185,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-simple-access/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.22.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", @@ -270,6 +211,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", @@ -295,20 +250,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helpers": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", - "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/highlight": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", @@ -323,46 +264,80 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/parser": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", - "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=4" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.1.tgz", - "integrity": "sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==", - "dev": true, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=4" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz", - "integrity": "sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==", - "dev": true, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.0.0" } }, "node_modules/@babel/runtime": { @@ -390,31 +365,11 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { + "node_modules/@babel/template/node_modules/@babel/types": { "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.23.4", "@babel/helper-validator-identifier": "^7.22.20", @@ -424,16 +379,6 @@ "node": ">=6.9.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@cypress/request": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", @@ -463,6 +408,20 @@ "node": ">= 6" } }, + "node_modules/@cypress/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/@cypress/xvfb": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", @@ -500,6 +459,16 @@ "stylis": "4.2.0" } }, + "node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + }, + "node_modules/@emotion/babel-plugin/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, "node_modules/@emotion/cache": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", @@ -512,10 +481,15 @@ "stylis": "4.2.0" } }, - "node_modules/@emotion/hash": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", - "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + "node_modules/@emotion/cache/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/cache/node_modules/@emotion/sheet": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" }, "node_modules/@emotion/is-prop-valid": { "version": "1.2.2", @@ -525,7 +499,7 @@ "@emotion/memoize": "^0.8.1" } }, - "node_modules/@emotion/memoize": { + "node_modules/@emotion/is-prop-valid/node_modules/@emotion/memoize": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" @@ -565,10 +539,20 @@ "csstype": "^3.0.2" } }, - "node_modules/@emotion/sheet": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", - "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + "node_modules/@emotion/serialize/node_modules/@emotion/hash": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + }, + "node_modules/@emotion/serialize/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/serialize/node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" }, "node_modules/@emotion/styled": { "version": "11.11.5", @@ -592,11 +576,6 @@ } } }, - "node_modules/@emotion/unitless": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", - "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" - }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", @@ -629,6 +608,24 @@ "node": ">=16" } }, + "node_modules/@es-joy/jsdoccomment/node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@es-joy/jsdoccomment/node_modules/jsdoc-type-pratt-parser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", + "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -1021,87 +1018,6 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", @@ -1136,42 +1052,6 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1191,114 +1071,44 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, + "node_modules/@mui/base": { + "version": "5.0.0-beta.40", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", + "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" }, "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@microsoft/tsdoc": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", - "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==", - "dev": true - }, - "node_modules/@microsoft/tsdoc-config": { - "version": "0.16.2", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz", - "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==", - "dev": true, - "dependencies": { - "@microsoft/tsdoc": "0.14.2", - "ajv": "~6.12.6", - "jju": "~1.4.0", - "resolve": "~1.19.0" - } - }, - "node_modules/@microsoft/tsdoc-config/node_modules/resolve": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", - "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", - "dev": true, - "dependencies": { - "is-core-module": "^2.1.0", - "path-parse": "^1.0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/@mui/base": { - "version": "5.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", - "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", - "dependencies": { - "@babel/runtime": "^7.23.9", - "@floating-ui/react-dom": "^2.0.8", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "@popperjs/core": "^2.11.8", - "clsx": "^2.1.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node_modules/@mui/base/node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" } }, "node_modules/@mui/core-downloads-tracker": { @@ -1379,13 +1189,14 @@ } } }, - "node_modules/@mui/private-theming": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", - "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", + "node_modules/@mui/styled-engine-sc": { + "version": "6.0.0-alpha.18", + "resolved": "https://registry.npmjs.org/@mui/styled-engine-sc/-/styled-engine-sc-6.0.0-alpha.18.tgz", + "integrity": "sha512-W3mqR1K01rPL0BVNTgGpIYxdbQ/nTAlwYaohRdmX7FZvbm1yKw9F90OIGxM503dfRMVBi6a/neYPgIUebcGsHw==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/utils": "^5.15.14", + "csstype": "^3.1.3", + "hoist-non-react-statics": "^3.3.2", "prop-types": "^15.8.1" }, "engines": { @@ -1396,22 +1207,20 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "styled-components": "^6.0.0" } }, - "node_modules/@mui/styled-engine": { - "version": "5.15.14", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", - "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", + "node_modules/@mui/system": { + "version": "5.15.15", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.15.tgz", + "integrity": "sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==", "dependencies": { "@babel/runtime": "^7.23.9", - "@emotion/cache": "^11.11.0", + "@mui/private-theming": "^5.15.14", + "@mui/styled-engine": "^5.15.14", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "clsx": "^2.1.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, @@ -1423,8 +1232,9 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.4.1", + "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { @@ -1433,17 +1243,19 @@ }, "@emotion/styled": { "optional": true + }, + "@types/react": { + "optional": true } } }, - "node_modules/@mui/styled-engine-sc": { - "version": "6.0.0-alpha.18", - "resolved": "https://registry.npmjs.org/@mui/styled-engine-sc/-/styled-engine-sc-6.0.0-alpha.18.tgz", - "integrity": "sha512-W3mqR1K01rPL0BVNTgGpIYxdbQ/nTAlwYaohRdmX7FZvbm1yKw9F90OIGxM503dfRMVBi6a/neYPgIUebcGsHw==", + "node_modules/@mui/system/node_modules/@mui/private-theming": { + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", + "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", "dependencies": { "@babel/runtime": "^7.23.9", - "csstype": "^3.1.3", - "hoist-non-react-statics": "^3.3.2", + "@mui/utils": "^5.15.14", "prop-types": "^15.8.1" }, "engines": { @@ -1454,20 +1266,22 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "styled-components": "^6.0.0" + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/@mui/system": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.15.tgz", - "integrity": "sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==", + "node_modules/@mui/system/node_modules/@mui/styled-engine": { + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", + "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", "dependencies": { "@babel/runtime": "^7.23.9", - "@mui/private-theming": "^5.15.14", - "@mui/styled-engine": "^5.15.14", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "clsx": "^2.1.0", + "@emotion/cache": "^11.11.0", "csstype": "^3.1.3", "prop-types": "^15.8.1" }, @@ -1479,9 +1293,8 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@emotion/react": "^11.5.0", + "@emotion/react": "^11.4.1", "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0" }, "peerDependenciesMeta": { @@ -1490,9 +1303,6 @@ }, "@emotion/styled": { "optional": true - }, - "@types/react": { - "optional": true } } }, @@ -1536,6 +1346,31 @@ } } }, + "node_modules/@mui/x-data-grid": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.1.1.tgz", + "integrity": "sha512-hNvz927lkAznFdy45QPE7mIZVyQhlqveHmTK9+SD0N1us4sSTij90uUJ/roTNDod0VA9f5GqWmNz+5h8ihpz6Q==", + "dependencies": { + "@babel/runtime": "^7.24.0", + "@mui/system": "^5.15.14", + "@mui/utils": "^5.15.14", + "clsx": "^2.1.0", + "prop-types": "^15.8.1", + "reselect": "^4.1.8" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.15.14", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + } + }, "node_modules/@mui/x-date-pickers": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.1.1.tgz", @@ -1601,50 +1436,6 @@ } } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@remix-run/router": { "version": "1.15.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", @@ -1654,9 +1445,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.0.tgz", - "integrity": "sha512-jwXtxYbRt1V+CdQSy6Z+uZti7JF5irRKF8hlKfEnF/xJpcNGuuiZMBvuoYM+x9sr9iWGnzrlM0+9hvQ1kgkf1w==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.1.tgz", + "integrity": "sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==", "cpu": [ "arm" ], @@ -1667,9 +1458,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.0.tgz", - "integrity": "sha512-fI9nduZhCccjzlsA/OuAwtFGWocxA4gqXGTLvOyiF8d+8o0fZUeSztixkYjcGq1fGZY3Tkq4yRvHPFxU+jdZ9Q==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.1.tgz", + "integrity": "sha512-Y/9OHLjzkunF+KGEoJr3heiD5X9OLa8sbT1lm0NYeKyaM3oMhhQFvPB0bNZYJwlq93j8Z6wSxh9+cyKQaxS7PQ==", "cpu": [ "arm64" ], @@ -1680,9 +1471,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.0.tgz", - "integrity": "sha512-BcnSPRM76/cD2gQC+rQNGBN6GStBs2pl/FpweW8JYuz5J/IEa0Fr4AtrPv766DB/6b2MZ/AfSIOSGw3nEIP8SA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.1.tgz", + "integrity": "sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==", "cpu": [ "arm64" ], @@ -1693,9 +1484,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.0.tgz", - "integrity": "sha512-LDyFB9GRolGN7XI6955aFeI3wCdCUszFWumWU0deHA8VpR3nWRrjG6GtGjBrQxQKFevnUTHKCfPR4IvrW3kCgQ==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.1.tgz", + "integrity": "sha512-2pYRzEjVqq2TB/UNv47BV/8vQiXkFGVmPFwJb+1E0IFFZbIX8/jo1olxqqMbo6xCXf8kabANhp5bzCij2tFLUA==", "cpu": [ "x64" ], @@ -1706,9 +1497,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.0.tgz", - "integrity": "sha512-ygrGVhQP47mRh0AAD0zl6QqCbNsf0eTo+vgwkY6LunBcg0f2Jv365GXlDUECIyoXp1kKwL5WW6rsO429DBY/bA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.1.tgz", + "integrity": "sha512-mS6wQ6Do6/wmrF9aTFVpIJ3/IDXhg1EZcQFYHZLHqw6AzMBjTHWnCG35HxSqUNphh0EHqSM6wRTT8HsL1C0x5g==", "cpu": [ "arm" ], @@ -1719,9 +1510,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.0.tgz", - "integrity": "sha512-x+uJ6MAYRlHGe9wi4HQjxpaKHPM3d3JjqqCkeC5gpnnI6OWovLdXTpfa8trjxPLnWKyBsSi5kne+146GAxFt4A==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.1.tgz", + "integrity": "sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==", "cpu": [ "arm64" ], @@ -1732,9 +1523,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.0.tgz", - "integrity": "sha512-nrRw8ZTQKg6+Lttwqo6a2VxR9tOroa2m91XbdQ2sUUzHoedXlsyvY1fN4xWdqz8PKmf4orDwejxXHjh7YBGUCA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.1.tgz", + "integrity": "sha512-nDY6Yz5xS/Y4M2i9JLQd3Rofh5OR8Bn8qe3Mv/qCVpHFlwtZSBYSPaU4mrGazWkXrdQ98GB//H0BirGR/SKFSw==", "cpu": [ "arm64" ], @@ -1745,9 +1536,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.0.tgz", - "integrity": "sha512-xV0d5jDb4aFu84XKr+lcUJ9y3qpIWhttO3Qev97z8DKLXR62LC3cXT/bMZXrjLF9X+P5oSmJTzAhqwUbY96PnA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.1.tgz", + "integrity": "sha512-im7HE4VBL+aDswvcmfx88Mp1soqL9OBsdDBU8NqDEYtkri0qV0THhQsvZtZeNNlLeCUQ16PZyv7cqutjDF35qw==", "cpu": [ "ppc64le" ], @@ -1758,9 +1549,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.0.tgz", - "integrity": "sha512-SDDhBQwZX6LPRoPYjAZWyL27LbcBo7WdBFWJi5PI9RPCzU8ijzkQn7tt8NXiXRiFMJCVpkuMkBf4OxSxVMizAw==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.1.tgz", + "integrity": "sha512-RWdiHuAxWmzPJgaHJdpvUUlDz8sdQz4P2uv367T2JocdDa98iRw2UjIJ4QxSyt077mXZT2X6pKfT2iYtVEvOFw==", "cpu": [ "riscv64" ], @@ -1771,9 +1562,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.0.tgz", - "integrity": "sha512-RxB/qez8zIDshNJDufYlTT0ZTVut5eCpAZ3bdXDU9yTxBzui3KhbGjROK2OYTTor7alM7XBhssgoO3CZ0XD3qA==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.1.tgz", + "integrity": "sha512-VMgaGQ5zRX6ZqV/fas65/sUGc9cPmsntq2FiGmayW9KMNfWVG/j0BAqImvU4KTeOOgYSf1F+k6at1UfNONuNjA==", "cpu": [ "s390x" ], @@ -1784,9 +1575,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.0.tgz", - "integrity": "sha512-C6y6z2eCNCfhZxT9u+jAM2Fup89ZjiG5pIzZIDycs1IwESviLxwkQcFRGLjnDrP+PT+v5i4YFvlcfAs+LnreXg==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.1.tgz", + "integrity": "sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==", "cpu": [ "x64" ], @@ -1797,9 +1588,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.0.tgz", - "integrity": "sha512-i0QwbHYfnOMYsBEyjxcwGu5SMIi9sImDVjDg087hpzXqhBSosxkE7gyIYFHgfFl4mr7RrXksIBZ4DoLoP4FhJg==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.1.tgz", + "integrity": "sha512-JNEG/Ti55413SsreTguSx0LOVKX902OfXIKVg+TCXO6Gjans/k9O6ww9q3oLGjNDaTLxM+IHFMeXy/0RXL5R/g==", "cpu": [ "x64" ], @@ -1810,9 +1601,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.0.tgz", - "integrity": "sha512-Fq52EYb0riNHLBTAcL0cun+rRwyZ10S9vKzhGKKgeD+XbwunszSY0rVMco5KbOsTlwovP2rTOkiII/fQ4ih/zQ==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.1.tgz", + "integrity": "sha512-ryS22I9y0mumlLNwDFYZRDFLwWh3aKaC72CWjFcFvxK0U6v/mOkM5Up1bTbCRAhv3kEIwW2ajROegCIQViUCeA==", "cpu": [ "arm64" ], @@ -1823,9 +1614,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.0.tgz", - "integrity": "sha512-e/PBHxPdJ00O9p5Ui43+vixSgVf4NlLsmV6QneGERJ3lnjIua/kim6PRFe3iDueT1rQcgSkYP8ZBBXa/h4iPvw==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.1.tgz", + "integrity": "sha512-TdloItiGk+T0mTxKx7Hp279xy30LspMso+GzQvV2maYePMAWdmrzqSNZhUpPj3CGw12aGj57I026PgLCTu8CGg==", "cpu": [ "ia32" ], @@ -1836,9 +1627,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.0.tgz", - "integrity": "sha512-aGg7iToJjdklmxlUlJh/PaPNa4PmqHfyRMLunbL3eaMO0gp656+q1zOKkpJ/CVe9CryJv6tAN1HDoR8cNGzkag==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.1.tgz", + "integrity": "sha512-wQGI+LY/Py20zdUPq+XCem7JcPOyzIJBm3dli+56DJsQOHbnXZFEwgmnC6el1TPAfC8lBT3m+z69RmLykNUbew==", "cpu": [ "x64" ], @@ -1861,6 +1652,20 @@ "@types/babel__traverse": "*" } }, + "node_modules/@types/babel__core/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@types/babel__generator": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", @@ -1870,6 +1675,20 @@ "@babel/types": "^7.0.0" } }, + "node_modules/@types/babel__generator/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@types/babel__template": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", @@ -1880,6 +1699,20 @@ "@babel/types": "^7.0.0" } }, + "node_modules/@types/babel__template/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@types/babel__traverse": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", @@ -1889,22 +1722,72 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, + "node_modules/@types/babel__traverse/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/mdast": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", + "integrity": "sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, "node_modules/@types/node": { - "version": "20.12.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.4.tgz", - "integrity": "sha512-E+Fa9z3wSQpzgYQdYmme5X3OTuejnnTx88A6p6vkkJosR3KBz+HpE3kqNm98VE6cfLFcISx7zW7MsJkH6KwbTw==", + "version": "20.12.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.6.tgz", + "integrity": "sha512-3KurE8taB8GCvZBPngVbp0lk5CKi8M9f9k1rsADh0Evdz5SzJ+Q+Hx9uHoFGsLnLnd1xmkDQr2hVhlA0Mn0lKQ==", "dev": true, "optional": true, "dependencies": { @@ -1922,9 +1805,9 @@ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/react": { - "version": "18.2.74", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.74.tgz", - "integrity": "sha512-9AEqNZZyBx8OdZpxzQlaFEVCSFUM2YXJH46yPOiOpm078k6ZLOCcuAzGum/zK8YBwY+dbahVNbHrbgrAwIRlqw==", + "version": "18.2.75", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.75.tgz", + "integrity": "sha512-+DNnF7yc5y0bHkBTiLKqXFe+L4B3nvOphiMY3tuA5X10esmjqk7smyBZzbGTy2vsiy/Bnzj8yFIBL8xhRacoOg==", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1970,6 +1853,11 @@ "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-n4sx2bqL0mW1tvDf/loQ+aMX7GQD3lc3fkCMC55VFNDu/vBOabO+LTIeXKM14xK0ppk5TUGcWRjiSpIlUpghKw==" }, + "node_modules/@types/unist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2043,6 +1931,71 @@ } } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", @@ -2060,6 +2013,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/type-utils": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", @@ -2087,7 +2053,7 @@ } } }, - "node_modules/@typescript-eslint/types": { + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", @@ -2100,7 +2066,7 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", @@ -2128,6 +2094,30 @@ } } }, + "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/utils": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", @@ -2153,6 +2143,71 @@ "eslint": "^7.0.0 || ^8.0.0" } }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/visitor-keys": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", @@ -2170,11 +2225,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, "node_modules/@vitejs/plugin-react": { "version": "4.2.1", @@ -2195,56 +2257,324 @@ "vite": "^4.2.0 || ^5.0.0" } }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "node_modules/@vitejs/plugin-react/node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { - "node": ">=8" + "node": ">=6.0.0" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/@vitejs/plugin-react/node_modules/@babel/core": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", "dev": true, "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.4", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/generator": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/helpers": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.1.tgz", + "integrity": "sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz", + "integrity": "sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/traverse": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@vitejs/plugin-react/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv/node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2269,6 +2599,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2279,14 +2621,18 @@ } }, "node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "dependencies": { - "color-convert": "^1.9.0" + "color-convert": "^2.0.1" }, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/arch": { @@ -2369,8 +2715,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -2396,6 +2741,16 @@ "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", "dev": true }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -2410,6 +2765,31 @@ "npm": ">=6" } }, + "node_modules/babel-plugin-macros/node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2458,12 +2838,13 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -2600,9 +2981,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001605", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001605.tgz", - "integrity": "sha512-nXwGlFWo34uliI9z3n6Qc0wZaf7zaZWA1CPZ169La5mV3I/gem7bst0vr5XQH5TJXZIMfDeZyOrZnSlVzKxxHQ==", + "version": "1.0.30001607", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001607.tgz", + "integrity": "sha512-WcvhVRjXLKFB/kmOFVwELtMxyhq3iM/MvmXcyCe2PNf166c39mptscOc/45TTS96n2gpNV2z7+NakArTWZCQ3w==", "dev": true, "funding": [ { @@ -2625,25 +3006,77 @@ "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", "dev": true }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chalk/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=0.8.0" + "node": ">=8" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/check-more-types": { @@ -2706,6 +3139,16 @@ "@colors/colors": "1.5.0" } }, + "node_modules/cli-table3/node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/cli-truncate": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", @@ -2731,17 +3174,22 @@ } }, "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { - "color-name": "1.1.3" + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" } }, "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/colorette": { "version": "2.0.20", @@ -2753,7 +3201,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -2761,6 +3208,15 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", @@ -2770,15 +3226,6 @@ "node": ">= 6" } }, - "node_modules/comment-parser": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", - "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", - "dev": true, - "engines": { - "node": ">= 12.0.0" - } - }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -2800,9 +3247,9 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, "node_modules/cosmiconfig": { "version": "7.1.0", @@ -2922,91 +3369,12 @@ "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, - "node_modules/cypress/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/cypress/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/cypress/node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cypress/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/cypress/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "node_modules/cypress/node_modules/proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true }, - "node_modules/cypress/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/cypress/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3028,7 +3396,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3041,6 +3408,18 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3068,11 +3447,30 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3117,9 +3515,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.726", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.726.tgz", - "integrity": "sha512-xtjfBXn53RORwkbyKvDfTajtnTp0OJoPOIBzXvkNbb7+YYvCHJflba3L7Txyx/6Fov3ov2bGPr/n5MTixmPhdQ==", + "version": "1.4.730", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.730.tgz", + "integrity": "sha512-oJRPo82XEqtQAobHpJIR3zW5YO3sSRRkPz2an4yxi1UvqhsGm54vR/wzTFV74a3soDOJ8CKW7ajOOX5ESzddwg==", "dev": true }, "node_modules/emitter-component": { @@ -3301,9 +3699,9 @@ } }, "node_modules/eslint-plugin-jsdoc": { - "version": "48.2.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.2.tgz", - "integrity": "sha512-S0Gk+rpT5w/ephKCncUY7kUsix9uE4B9XI8D/fS1/26d8okE+vZsuG1IvIt4B6sJUdQqsnzi+YXfmh+HJG11CA==", + "version": "48.2.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-48.2.3.tgz", + "integrity": "sha512-r9DMAmFs66VNvNqRLLjHejdnJtILrt3xGi+Qx0op0oRfFGVpOR1Hb3BC++MacseHx93d8SKYPhyrC9BS7Os2QA==", "dev": true, "dependencies": { "@es-joy/jsdoccomment": "~0.42.0", @@ -3323,6 +3721,15 @@ "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, + "node_modules/eslint-plugin-jsdoc/node_modules/comment-parser": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.1.tgz", + "integrity": "sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==", + "dev": true, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", @@ -3354,6 +3761,37 @@ "@microsoft/tsdoc-config": "0.16.2" } }, + "node_modules/eslint-plugin-tsdoc/node_modules/@microsoft/tsdoc": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.14.2.tgz", + "integrity": "sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==", + "dev": true + }, + "node_modules/eslint-plugin-tsdoc/node_modules/@microsoft/tsdoc-config": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.16.2.tgz", + "integrity": "sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.14.2", + "ajv": "~6.12.6", + "jju": "~1.4.0", + "resolve": "~1.19.0" + } + }, + "node_modules/eslint-plugin-tsdoc/node_modules/resolve": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.19.0.tgz", + "integrity": "sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==", + "dev": true, + "dependencies": { + "is-core-module": "^2.1.0", + "path-parse": "^1.0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -3382,125 +3820,93 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/eslint/node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { - "type-fest": "^0.20.2" + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/eslint/node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/eslint/node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" }, "engines": { - "node": "*" + "node": ">=10.10.0" } }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/eslint/node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": ">=8" + "node": ">= 8" } }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/eslint/node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "engines": { - "node": ">=10" + "node": ">= 8" + } + }, + "node_modules/eslint/node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 8" } }, + "node_modules/eslint/node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -3551,6 +3957,15 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3604,8 +4019,7 @@ "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, "node_modules/extract-zip": { "version": "2.0.1", @@ -3642,34 +4056,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3789,6 +4175,25 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -3799,17 +4204,16 @@ } }, "node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", + "combined-stream": "^1.0.8", "mime-types": "^2.1.12" }, "engines": { - "node": ">= 0.12" + "node": ">= 6" } }, "node_modules/fs-extra": { @@ -3948,28 +4352,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/global-dirs": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", @@ -3986,12 +4368,18 @@ } }, "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, "engines": { - "node": ">=4" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/globby": { @@ -4014,6 +4402,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby/node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/globby/node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/globby/node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/globby/node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/globby/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -4039,11 +4490,12 @@ "dev": true }, "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/has-property-descriptors": { @@ -4093,6 +4545,44 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz", + "integrity": "sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -4115,6 +4605,15 @@ "void-elements": "3.1.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.0.tgz", + "integrity": "sha512-/sXbVCWayk6GDVg3ctOX6nxaVj7So40FcFAnWlWGNAB1LpYKcV5Cd10APjPjW80O7zYW2MsjBV4zZ7IZO5fVow==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -4139,9 +4638,9 @@ } }, "node_modules/i18next": { - "version": "23.10.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.10.1.tgz", - "integrity": "sha512-NDiIzFbcs3O9PXpfhkjyf7WdqFn5Vq6mhzhtkXzj51aOcNuPNcTwuYNuXCpHsanZGHlHKL35G7huoFeVic1hng==", + "version": "23.11.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.11.0.tgz", + "integrity": "sha512-VwFtlgy2LDbY0Qs6VfekIm6mv5/JmSJrtBf4aszl7Vby8+GcBlri0/7dkMZXmzTfiBMPUPBOmYCdQK7K4emkGQ==", "dev": true, "funding": [ { @@ -4270,6 +4769,33 @@ "node": ">=10" } }, + "node_modules/inline-style-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", + "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4313,6 +4839,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4343,6 +4878,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-installed-globally": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", @@ -4377,6 +4921,17 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4453,15 +5008,6 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.0.0.tgz", - "integrity": "sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==", - "dev": true, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -4542,10 +5088,24 @@ "node >=0.6.0" ], "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/jsprim/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" } }, "node_modules/jszip": { @@ -4559,6 +5119,25 @@ "setimmediate": "^1.0.5" } }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4630,6 +5209,15 @@ } } }, + "node_modules/listr2/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4679,192 +5267,696 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", "dev": true, "dependencies": { - "color-convert": "^2.0.1" + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" }, "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" }, "engines": { "node": ">=10" }, "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-symbols/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, "dependencies": { - "color-name": "~1.1.4" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=7.0.0" + "node": ">=8" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", + "integrity": "sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown/node_modules/micromark": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", + "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.0.tgz", + "integrity": "sha512-fGCu8eWdKUKNu5mohVGkhBXCXGnOTLuFqOvGMvdikr+J1w7lDJgxThOKpwRWzzbyXAU2hhSwsmssOY4yTokluw==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.2.tgz", + "integrity": "sha512-eKMQDeywY2wlHc97k5eD8VC+9ASMjN8ItEZQNGwJ6E0XWKiW/Z0V5/H8pvoXUf+y+Mj0VIgeRRbujBmFn4FTyA==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-remove-position": "^5.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", + "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-mdx-jsx/node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.1.0.tgz", + "integrity": "sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast/node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", + "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz", + "integrity": "sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", + "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", + "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", + "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", + "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" + "node_modules/micromark-util-chunked": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", + "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/log-symbols/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, + "node_modules/micromark-util-classify-character": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", + "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/log-update": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", - "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", - "dev": true, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", + "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "ansi-escapes": "^4.3.0", - "cli-cursor": "^3.1.0", - "slice-ansi": "^4.0.0", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", + "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/log-update/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "node_modules/micromark-util-decode-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", + "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/log-update/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "node_modules/micromark-util-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", + "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", + "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", + "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", + "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" + "micromark-util-types": "^2.0.0" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", + "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.0.tgz", + "integrity": "sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "dependencies": { - "yallist": "^3.0.2" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/merge-stream": { + "node_modules/micromark-util-symbol": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } + "node_modules/micromark-util-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", + "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] }, "node_modules/micromatch": { "version": "4.0.5", @@ -4883,7 +5975,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4892,7 +5983,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -4910,18 +6000,15 @@ } }, "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "*" } }, "node_modules/minimist": { @@ -4936,8 +6023,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.7", @@ -5302,11 +6388,19 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-from-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", - "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", - "dev": true + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/psl": { "version": "1.9.0", @@ -5424,6 +6518,31 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" }, + "node_modules/react-markdown": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.0.1.tgz", + "integrity": "sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -5478,29 +6597,41 @@ "react-dom": ">=16.6.0" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + "node_modules/remark-rehype": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.0.tgz", + "integrity": "sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, "node_modules/request-progress": { "version": "3.0.0", @@ -5517,21 +6648,10 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "node_modules/reselect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", + "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" }, "node_modules/resolve-from": { "version": "4.0.0", @@ -5586,9 +6706,9 @@ } }, "node_modules/rollup": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.0.tgz", - "integrity": "sha512-Qe7w62TyawbDzB4yt32R0+AbIo6m1/sqO7UPzFS8Z/ksL5mrfhA0v4CavfdmFav3D+ub4QeAgsGEe84DoWe/nQ==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz", + "integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -5601,21 +6721,21 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.0", - "@rollup/rollup-android-arm64": "4.14.0", - "@rollup/rollup-darwin-arm64": "4.14.0", - "@rollup/rollup-darwin-x64": "4.14.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.0", - "@rollup/rollup-linux-arm64-gnu": "4.14.0", - "@rollup/rollup-linux-arm64-musl": "4.14.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.0", - "@rollup/rollup-linux-riscv64-gnu": "4.14.0", - "@rollup/rollup-linux-s390x-gnu": "4.14.0", - "@rollup/rollup-linux-x64-gnu": "4.14.0", - "@rollup/rollup-linux-x64-musl": "4.14.0", - "@rollup/rollup-win32-arm64-msvc": "4.14.0", - "@rollup/rollup-win32-ia32-msvc": "4.14.0", - "@rollup/rollup-win32-x64-msvc": "4.14.0", + "@rollup/rollup-android-arm-eabi": "4.14.1", + "@rollup/rollup-android-arm64": "4.14.1", + "@rollup/rollup-darwin-arm64": "4.14.1", + "@rollup/rollup-darwin-x64": "4.14.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.14.1", + "@rollup/rollup-linux-arm64-gnu": "4.14.1", + "@rollup/rollup-linux-arm64-musl": "4.14.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.14.1", + "@rollup/rollup-linux-riscv64-gnu": "4.14.1", + "@rollup/rollup-linux-s390x-gnu": "4.14.1", + "@rollup/rollup-linux-x64-gnu": "4.14.1", + "@rollup/rollup-linux-x64-musl": "4.14.1", + "@rollup/rollup-win32-arm64-msvc": "4.14.1", + "@rollup/rollup-win32-ia32-msvc": "4.14.1", + "@rollup/rollup-win32-x64-msvc": "4.14.1", "fsevents": "~2.3.2" } }, @@ -5642,15 +6762,6 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -5700,24 +6811,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/semver/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5813,39 +6906,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/slice-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -5862,6 +6922,15 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", @@ -5944,6 +7013,19 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5977,6 +7059,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.6.tgz", + "integrity": "sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==", + "dependencies": { + "inline-style-parser": "0.2.3" + } + }, "node_modules/styled-components": { "version": "6.1.8", "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.8.tgz", @@ -6012,6 +7102,11 @@ "@emotion/memoize": "^0.8.1" } }, + "node_modules/styled-components/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, "node_modules/styled-components/node_modules/@emotion/unitless": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", @@ -6027,25 +7122,24 @@ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" }, - "node_modules/styled-components/node_modules/tslib": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" - }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" }, "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, "dependencies": { - "has-flag": "^3.0.0" + "has-flag": "^4.0.0" }, "engines": { - "node": ">=4" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { @@ -6139,6 +7233,24 @@ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", "dev": true }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -6152,10 +7264,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -6188,9 +7299,9 @@ } }, "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "engines": { "node": ">=10" @@ -6200,9 +7311,9 @@ } }, "node_modules/typescript": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", - "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", + "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -6219,6 +7330,100 @@ "dev": true, "optional": true }, + "node_modules/unified": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.4.tgz", + "integrity": "sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -6267,15 +7472,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -6300,25 +7496,32 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], + "node_modules/vfile": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", + "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, "node_modules/vite": { "version": "5.2.8", @@ -6460,39 +7663,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/wrap-ansi/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6500,9 +7670,9 @@ "dev": true }, "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, "node_modules/yaml": { @@ -6534,6 +7704,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 3d1fd7d6..51fdab0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,8 @@ "@mui/icons-material": "^5.15.10", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", + "@mui/x-data-grid": "^7.1.1", + "axios": "^1.6.8", "@mui/x-date-pickers": "^7.1.1", "dayjs": "^1.11.10", "i18next-browser-languagedetector": "^7.2.0", @@ -25,6 +27,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", + "react-markdown": "^9.0.1", "react-router-dom": "^6.22.1", "stream": "^0.0.2", "styled-components": "^6.1.8" diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 3ad744c2..d0a612e8 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -14,5 +14,28 @@ "courseName": "Course Name", "submit": "Submit", "emptyCourseNameError": "Course name should not be empty" + }, + "projectView": { + "submitNetworkError": "Failed to upload file, please try again.", + "selected": "Selected", + "submit": "Submit", + "previousSubmissions": "Previous Submissions", + "noFileSelected": "No file selected", + "submissionGrid": { + "late": "Late", + "fail": "Fail", + "success": "Success", + "running": "Running", + "submitTime": "Time submitted", + "status": "Status" + } + }, + "time": { + "yearsAgo": "years ago", + "monthsAgo": "months ago", + "daysAgo": "days ago", + "hoursAgo": "hours ago", + "minutesAgo": "minutes ago", + "justNow": "just now" } } \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 5135d0a9..f30d871e 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -14,5 +14,28 @@ "courseName": "Vak Naam", "submit": "Opslaan", "emptyCourseNameError": "Vak naam mag niet leeg zijn" + }, + "projectView": { + "submitNetworkError": "Er is iets mislopen bij het opslaan van uw indiening. Probeer het later opnieuw.", + "selected": "Geselecteerd", + "submit": "Indienen", + "previousSubmissions": "Vorige indieningen", + "noFileSelected": "Er is geen bestand geselecteerd", + "submissionGrid": { + "late": "Te laat", + "fail": "Gefaald", + "success": "Succesvol", + "running": "Aan het uitvoeren", + "submitTime": "Indientijd", + "status": "Status" + } + }, + "time": { + "yearsAgo": "jaren geleden", + "monthsAgo": "maanden geleden", + "daysAgo": "dagen geleden", + "hoursAgo": "uur geleden", + "minutesAgo": "minuten geleden", + "justNow": "Zonet" } } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e17cf30..eb541cd4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { BrowserRouter,Route,Routes } from "react-router-dom"; import { Header } from "./components/Header/Header"; import Home from "./pages/home/Home"; import LanguagePath from "./components/LanguagePath"; +import ProjectView from "./pages/project/projectView/ProjectView"; /** * This component is the main application component that will be rendered by the ReactDOM. @@ -15,6 +16,9 @@ function App(): JSX.Element { <Route index element={<Home />} /> <Route path=":lang" element={<LanguagePath/>}> <Route path="home" element={<Home />} /> + <Route path="project" > + <Route path=":projectId" element={<ProjectView />}/> + </Route> </Route> </Routes> </BrowserRouter> diff --git a/frontend/src/components/FolderUpload/FolderUpload.tsx b/frontend/src/components/FolderUpload/FolderUpload.tsx index 463ddb97..8a94de78 100644 --- a/frontend/src/components/FolderUpload/FolderUpload.tsx +++ b/frontend/src/components/FolderUpload/FolderUpload.tsx @@ -99,7 +99,7 @@ const FolderDragDrop: React.FC<FolderDragDropProps> = ({ } return ( - <Grid container direction="column" style={{ margin: "1rem" }} spacing={2}> + <Grid container direction="column" spacing={2}> <Grid item> <Paper onDragOver={handleDragOver} diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx new file mode 100644 index 00000000..8ce28644 --- /dev/null +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -0,0 +1,115 @@ +import { + Card, + CardContent, + CardHeader, + Container, + Grid, + Link, + Stack, + Typography, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import Markdown from "react-markdown"; +import { useParams } from "react-router-dom"; +import SubmissionCard from "./SubmissionCard"; +import { Course } from "../../../types/course"; +import { Title } from "../../../components/Header/Title"; + +const API_URL = import.meta.env.VITE_API_HOST; + +interface Project { + title: string; + description: string; +} + +/** + * + * @returns - ProjectView component which displays the project details + * and submissions of the current user for that project + */ +export default function ProjectView() { + const { projectId } = useParams<{ projectId: string }>(); + const [projectData, setProjectData] = useState<Project | null>(null); + const [courseData, setCourseData] = useState<Course | null>(null); + const [assignmentRawText, setAssignmentRawText] = useState<string>(""); + + useEffect(() => { + fetch(`${API_URL}/projects/${projectId}`, { + headers: { Authorization: "teacher" }, + }).then((response) => { + if (response.ok) { + response.json().then((data) => { + const projectData = data["data"]; + setProjectData(projectData); + fetch(`${API_URL}/courses/${projectData.course_id}`, { + headers: { Authorization: "teacher" }, + }).then((response) => { + if (response.ok) { + response.json().then((data) => { + setCourseData(data["data"]); + }); + } + }); + }); + } + }); + + fetch(`${API_URL}/projects/${projectId}/assignment`, { + headers: { Authorization: "teacher" }, + }).then((response) => { + if (response.ok) { + response.text().then((data) => setAssignmentRawText(data)); + } + }); + }, [projectId]); + + if (!projectId) return null; + + return ( + <Grid + width="100%" + container + direction="column" + rowGap="2rem" + margin="2rem 0" + > + <Grid item sm={12}> + <Container> + {projectData && ( + <Card> + <Title title={projectData.title}/> + <CardHeader + color="secondary" + title={projectData.title} + subheader={ + <> + <Stack direction="row" spacing={2}> + <Typography>{projectData.description}</Typography> + <Typography flex="1" /> + {courseData && ( + <Link href={`/courses/${courseData.course_id}`}> + <Typography>{courseData.name}</Typography> + </Link> + )} + </Stack> + </> + } + /> + <CardContent> + <Markdown>{assignmentRawText}</Markdown> + </CardContent> + </Card> + )} + </Container> + </Grid> + <Grid item sm={12}> + <Container> + <SubmissionCard + submissionUrl={`${API_URL}/submissions`} + projectId={projectId} + /> + </Container> + </Grid> + </Grid> + ); +} diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx new file mode 100644 index 00000000..c223aa27 --- /dev/null +++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx @@ -0,0 +1,150 @@ +import { + Alert, + Button, + Card, + CardContent, + CardHeader, + Grid, + IconButton, + LinearProgress, + Typography, +} from "@mui/material"; +import SendIcon from "@mui/icons-material/Send"; +import { useEffect, useState } from "react"; +import FolderDragDrop from "../../../components/FolderUpload/FolderUpload"; +import axios from "axios"; +import { useTranslation } from "react-i18next"; +import SubmissionsGrid from "./SubmissionsGrid"; +import { Submission } from "../../../types/submission"; + +interface SubmissionCardProps { + regexRequirements?: string[]; + submissionUrl: string; + projectId: string; +} + +/** + * + * @param params - regexRequirements, submissionUrl, projectId + * @returns - SubmissionCard component which allows the user to submit files + * and view previous submissions + */ +export default function SubmissionCard({ + regexRequirements, + submissionUrl, + projectId, +}: SubmissionCardProps) { + const { t } = useTranslation('translation', { keyPrefix: 'projectView' }); + const [activeTab, setActiveTab] = useState("submit"); + const [selectedFile, setSelectedFile] = useState<File | null>(null); + const [uploadProgress, setUploadProgress] = useState<number | null>(null); + const [errorMessage, setErrorMessage] = useState<string | null>(null); + + const [previousSubmissions, setPreviousSubmissions] = useState<Submission[]>([]); + const handleFileDrop = (file: File) => { + setSelectedFile(file); + }; + + useEffect(() => { + + fetch(`${submissionUrl}?project_id=${projectId}`, { + headers: { Authorization: "teacher" } + }).then((response) => { + if (response.ok) { + response.json().then((data) => { + setPreviousSubmissions(data["data"]); + }); + } + }) + }, [projectId, submissionUrl]); + + const handleSubmit = async () => { + const form = new FormData(); + if (!selectedFile) { + setErrorMessage(t("noFileSelected")); + return; + } + form.append("files", selectedFile); + form.append("project_id", projectId); + form.append("uid", "teacher"); + try { + const response = await axios.post(submissionUrl, form, { + headers: { + "Content-Type": "multipart/form-data", + Authorization: "teacher", + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + setUploadProgress( + Math.round((progressEvent.loaded * 100) / progressEvent.total) + ); + } + }, + }); + + if (response.status === 201) { + setSelectedFile(null); + setPreviousSubmissions((prev) => [...prev, response.data["data"]]); + setActiveTab("submissions"); + } else { + setErrorMessage(t("submitNetworkError")); + } + } catch (error) { + setErrorMessage(t("submitNetworkError")); + } + + setUploadProgress(null); + }; + + return ( + <Card> + <CardHeader + title={ + <> + <Button onClick={() => setActiveTab("submit")}>{t("submit")}</Button> + <Button onClick={() => setActiveTab("submissions")}> + {t("previousSubmissions")} + </Button> + </> + } + /> + <CardContent> + {activeTab === "submit" ? ( + <Grid container direction="column" rowGap={1}> + <Grid item> + <FolderDragDrop + regexRequirements={regexRequirements} + onFileDrop={handleFileDrop} + onWrongInput={(message) => setErrorMessage(message)} + /> + </Grid> + <Grid item> + {selectedFile && ( + <Typography variant="subtitle1">{`${t("selected")}: ${selectedFile.name}`}</Typography> + )} + </Grid> + <Grid item> + <IconButton disabled={!selectedFile} onClick={handleSubmit}> + <SendIcon /> + </IconButton> + </Grid> + <Grid item> + {uploadProgress && ( + <LinearProgress variant="determinate" value={uploadProgress} /> + )} + </Grid> + <Grid item> + {errorMessage && ( + <Alert severity="error" onClose={() => setErrorMessage(null)}> + {errorMessage} + </Alert> + )} + </Grid> + </Grid> + ) : ( + <SubmissionsGrid submissionUrl={submissionUrl} rows={previousSubmissions}/> + )} + </CardContent> + </Card> + ); +} diff --git a/frontend/src/pages/project/projectView/SubmissionsGrid.tsx b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx new file mode 100644 index 00000000..46a56f73 --- /dev/null +++ b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx @@ -0,0 +1,92 @@ +import { DataGrid, GridColDef } from "@mui/x-data-grid"; +import DownloadIcon from "@mui/icons-material/Download"; +import { IconButton } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { timeDifference } from "../../../utils/date-utils"; +import { Submission } from "../../../types/submission"; + +interface SubmissionsGridProps { + submissionUrl: string; + rows: Submission[]; +} + +/** + * + * @param param - submissionUrl, rows + * @returns - SubmissionsGrid component which displays the submissions of the current user + */ +export default function SubmissionsGrid({ + submissionUrl, + rows, +}: SubmissionsGridProps) { + const { t } = useTranslation("translation", { + keyPrefix: "projectView.submissionGrid", + }); + + const stateMapper = { + LATE: t("late"), + FAIL: t("fail"), + RUNNING: t("running"), + SUCCESS: t("success"), + }; + + const columns: GridColDef[] = [ + { + field: "id", + type: "string", + width: 50, + headerName: "", + }, + { + field: "submission_time", + headerName: t("submitTime"), + type: "string", + flex: 1, + valueFormatter: (value) => timeDifference(value), + }, + { + field: "submission_status", + headerName: t("status"), + type: "string", + flex: 1, + valueFormatter: (value) => stateMapper[value], + }, + { + field: "actions", + type: "actions", + width: 50, + getActions: (props) => [ + <IconButton href={`${submissionUrl}/${props.id}/download`}> + <DownloadIcon /> + </IconButton>, + ], + }, + ]; + + return ( + <DataGrid + autosizeOnMount + columns={columns} + disableColumnResize + disableColumnFilter + disableColumnSelector + disableColumnSorting + disableDensitySelector + disableColumnMenu + disableVirtualization + showColumnVerticalBorder={false} + showCellVerticalBorder={false} + disableRowSelectionOnClick + disableEval + disableMultipleRowSelection + hideFooter + autoHeight + sortModel={[{ field: "submission_time", sort: "desc" }]} + getRowId={(row) => { + const urlTags = row.submission_id.split("/"); + return urlTags[urlTags.length - 1]; + }} + rows={rows} + /> + ); +} diff --git a/frontend/src/types/course.ts b/frontend/src/types/course.ts new file mode 100644 index 00000000..6b365ff9 --- /dev/null +++ b/frontend/src/types/course.ts @@ -0,0 +1,4 @@ +export interface Course { + name: string; + course_id: string; +} diff --git a/frontend/src/types/submission.ts b/frontend/src/types/submission.ts new file mode 100644 index 00000000..4522dbac --- /dev/null +++ b/frontend/src/types/submission.ts @@ -0,0 +1,5 @@ +export interface Submission { + submission_id: string; + submission_time: string; + submission_status: string; +} diff --git a/frontend/src/utils/date-utils.ts b/frontend/src/utils/date-utils.ts new file mode 100644 index 00000000..59fe2db9 --- /dev/null +++ b/frontend/src/utils/date-utils.ts @@ -0,0 +1,27 @@ +import i18next from "i18next"; + +/** + * + * @param date - date string to be converted to time difference + * @returns - time difference between the current date and the given date + */ +export function timeDifference(date: string) { + const t = (key: string) => { + return i18next.t(`time.${key}`); + }; + + const current = new Date(); + const previous = new Date(date); + const diff = current.getTime() - previous.getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(months / 12); + if (years > 0) return `${years} ${t("yearsAgo")}`; + if (months > 0) return `${months} ${t("monthsAgo")}`; + if (days > 0) return `${days} ${t("daysAgo")}`; + if (hours > 0) return `${hours} ${t("hoursAgo")}`; + if (minutes > 0) return `${minutes} ${t("minutesAgo")}`; + return t("justNow"); +} From 99a8657d845a5f25afba63c3dfce5e50c2a1bf01 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 10 Apr 2024 15:02:08 +0200 Subject: [PATCH 263/377] Fixing all tests --- .../courses/course_admin_relation.py | 14 +++++++++-- .../endpoints/courses/course_details.py | 23 +++++++++++++++++++ .../courses/course_student_relation.py | 11 +++++++++ backend/project/endpoints/courses/courses.py | 15 ++++++++++++ .../tests/endpoints/course/courses_test.py | 2 +- backend/tests/endpoints/endpoint.py | 1 + 6 files changed, 63 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index 16d26af6..e77da93c 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -57,16 +57,24 @@ def post(self, course_id): """ abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") data = request.get_json() + if len([key for key in data.keys() if key != "admin_uid"]) != 0: + return json_message("Incorrect data"), 400 assistant = data.get("admin_uid") abort_if_not_teacher_or_none_assistant(course_id, assistant) query = User.query.filter_by(uid=assistant) new_admin = execute_query_abort_if_db_error(query, abort_url) + if not new_admin: message = ( "User to make admin was not found, please request with a valid uid" ) - return json_message(message), 404 + return json_message(message), 400 + + query = CourseAdmin.query.filter_by(uid=assistant) + new_admin = execute_query_abort_if_db_error(query, abort_url) + if new_admin: + return json_message("Admin already added to the course"), 400 return insert_into_model( CourseAdmin, @@ -82,6 +90,8 @@ def delete(self, course_id): """ abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") data = request.get_json() + if len([key for key in data.keys() if key != "admin_uid"]) != 0: + return json_message("Incorrect data"), 400 assistant = data.get("admin_uid") abort_if_not_teacher_or_none_assistant(course_id, assistant) @@ -89,7 +99,7 @@ def delete(self, course_id): admin_relation = execute_query_abort_if_db_error(query, abort_url) if not admin_relation: message = "Course with given admin not found" - return json_message(message), 404 + return json_message(message), 400 delete_abort_if_error(admin_relation, abort_url) commit_abort_if_error(abort_url) diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index cfbfe5ca..9af1c046 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -18,8 +18,10 @@ from project.models.course_relation import CourseAdmin, CourseStudent from project.db_in import db +from project.endpoints.courses.courses_utils import execute_query_abort_if_db_error, json_message from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model from project.utils.authentication import login_required, authorize_teacher_of_course +from project.models.user import User, Role load_dotenv() API_URL = getenv("API_HOST") @@ -106,6 +108,27 @@ def patch(self, course_id): This function will update the course with course_id """ + if not all(hasattr(Course, key) for key in request.json.keys()): + return json_message("Incorrect data"), 400 + + if "name" in request.json.keys(): + name = request.json.get("name") + if name is None or not isinstance(name, str): + return json_message("Name field does not have the correct type"), 400 + + if "ufora_id" in request.json.keys(): + ufora_id = request.json.get("ufora_id") + if not isinstance(ufora_id, str): + return json_message("ufora_id field does not have the correct type"), 400 + + if "teacher" in request.json.keys(): + teacher = request.json.get("teacher") + if teacher is None or not isinstance(teacher, str): + return json_message("teacher does not have the correct type"), 400 + user = execute_query_abort_if_db_error(User.query.filter_by(uid=teacher), RESPONSE_URL) + if user.role != Role.TEACHER: + return json_message("teacher does not have the correct role"), 400 + return patch_by_id_from_model( Course, "course_id", diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 421666bd..4c3526af 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -14,6 +14,7 @@ from flask_restful import Resource from project.db_in import db +from project.models.user import User from project.models.course_relation import CourseStudent from project.endpoints.courses.courses_utils import ( execute_query_abort_if_db_error, @@ -65,12 +66,18 @@ def post(self, course_id): """ abort_url = f"{API_URL}/courses/{course_id}/students" data = request.get_json() + if len([key for key in data.keys() if key != "students"]) != 0: + return json_message("Incorrect data"), 400 student_uids = data.get("students") abort_if_none_uid_student_uids_or_non_existant_course_id( course_id, student_uids ) for uid in student_uids: + query = User.query.filter_by(uid=uid) + user = execute_query_abort_if_db_error(query, abort_url) + if not user: + return json_message("User does not exist"), 400 query = CourseStudent.query.filter_by(uid=uid, course_id=course_id) student_relation = execute_query_abort_if_db_error(query, abort_url) if student_relation: @@ -96,6 +103,8 @@ def delete(self, course_id): """ abort_url = f"{API_URL}/courses/{str(course_id)}/students" data = request.get_json() + if len([key for key in data.keys() if key != "students"]) != 0: + return json_message("Incorrect data"), 400 student_uids = data.get("students") abort_if_none_uid_student_uids_or_non_existant_course_id( course_id, student_uids @@ -106,6 +115,8 @@ def delete(self, course_id): student_relation = execute_query_abort_if_db_error(query, abort_url) if student_relation: delete_abort_if_error(student_relation, abort_url) + else: + return json_message("Not a student of the course"), 400 commit_abort_if_error(abort_url) response = json_message("Users were succesfully removed from the course") diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index 7b494b04..e1f6af75 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -15,6 +15,7 @@ from project.models.course import Course from project.utils.query_agent import query_selected_from_model, insert_into_model from project.utils.authentication import login_required, authorize_teacher +from project.endpoints.courses.courses_utils import json_message load_dotenv() API_URL = getenv("API_HOST") @@ -44,6 +45,20 @@ def post(self, teacher_id=None): This function will create a new course if the body of the post contains a name and uid is an admin or teacher """ + + if not all(hasattr(Course, key) for key in request.json.keys()): + return json_message("The data contains an incorrect field"), 400 + + if "name" in request.json.keys(): + name = request.json.get("name") + if name is None or not isinstance(name, str): + return json_message("Name field does not have the correct type"), 400 + + if "ufora_id" in request.json.keys(): + ufora_id = request.json.get("ufora_id") + if not isinstance(ufora_id, str): + return json_message("ufora_id field does not have the correct type"), 400 + req = request.json req["teacher"] = teacher_id return insert_into_model( diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index d83c0c89..e8a7872e 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -80,7 +80,7 @@ def test_authorization(self, auth_test: tuple[str, Any, str, bool]): ) + \ data_field_type_tests("/courses/@course_id/admins", "post", "teacher", {"admin_uid": "admin_other"}, - {"admin_uid": [None, "no_user", "student", "admin"]} + {"admin_uid": [None, "no_user", "admin"]} ) + \ data_field_type_tests("/courses/@course_id/admins", "delete", "teacher", {"admin_uid": ["admin"]}, diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index 8b8f6b9a..c3d1bb25 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -87,4 +87,5 @@ def data_field_type(self, test: tuple[str, Any, str, dict[str, Any]]): endpoint, method, token, data = test response = method(endpoint, headers = {"Authorization": token}, json = data) + print("TESTING", response.status_code) assert response.status_code == 400 From efe31d7592faed665eab246044ad9033c63a51e3 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 10 Apr 2024 15:29:51 +0200 Subject: [PATCH 264/377] cleanup --- .../project/endpoints/courses/course_admin_relation.py | 9 ++++----- backend/project/endpoints/courses/course_details.py | 10 ++++++---- .../endpoints/courses/course_student_relation.py | 8 ++++---- backend/project/endpoints/courses/courses.py | 4 ++-- backend/project/utils/misc.py | 5 ----- backend/project/utils/query_agent.py | 4 ++-- 6 files changed, 18 insertions(+), 22 deletions(-) diff --git a/backend/project/endpoints/courses/course_admin_relation.py b/backend/project/endpoints/courses/course_admin_relation.py index e77da93c..a208d6b9 100644 --- a/backend/project/endpoints/courses/course_admin_relation.py +++ b/backend/project/endpoints/courses/course_admin_relation.py @@ -57,14 +57,13 @@ def post(self, course_id): """ abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") data = request.get_json() - if len([key for key in data.keys() if key != "admin_uid"]) != 0: - return json_message("Incorrect data"), 400 + if any(key != "admin_uid" for key in data.keys()): + return json_message("Incorrect data field given"), 400 assistant = data.get("admin_uid") abort_if_not_teacher_or_none_assistant(course_id, assistant) query = User.query.filter_by(uid=assistant) new_admin = execute_query_abort_if_db_error(query, abort_url) - if not new_admin: message = ( "User to make admin was not found, please request with a valid uid" @@ -90,8 +89,8 @@ def delete(self, course_id): """ abort_url = urljoin(f"{RESPONSE_URL}/" , f"{str(course_id)}/", "admins") data = request.get_json() - if len([key for key in data.keys() if key != "admin_uid"]) != 0: - return json_message("Incorrect data"), 400 + if any(key != "admin_uid" for key in data.keys()): + return json_message("Incorrect data field given"), 400 assistant = data.get("admin_uid") abort_if_not_teacher_or_none_assistant(course_id, assistant) diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 9af1c046..4e3cca0a 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -114,20 +114,22 @@ def patch(self, course_id): if "name" in request.json.keys(): name = request.json.get("name") if name is None or not isinstance(name, str): - return json_message("Name field does not have the correct type"), 400 + return json_message("The name field does not have the correct type"), 400 if "ufora_id" in request.json.keys(): ufora_id = request.json.get("ufora_id") if not isinstance(ufora_id, str): - return json_message("ufora_id field does not have the correct type"), 400 + return json_message("The ufora_id field does not have the correct type"), 400 if "teacher" in request.json.keys(): teacher = request.json.get("teacher") if teacher is None or not isinstance(teacher, str): - return json_message("teacher does not have the correct type"), 400 + return json_message("The teacher field does not have the correct type"), 400 user = execute_query_abort_if_db_error(User.query.filter_by(uid=teacher), RESPONSE_URL) if user.role != Role.TEACHER: - return json_message("teacher does not have the correct role"), 400 + return json_message( + "The user given in the teacher field does not have the correct role" + ), 400 return patch_by_id_from_model( Course, diff --git a/backend/project/endpoints/courses/course_student_relation.py b/backend/project/endpoints/courses/course_student_relation.py index 4c3526af..20d0a3cc 100644 --- a/backend/project/endpoints/courses/course_student_relation.py +++ b/backend/project/endpoints/courses/course_student_relation.py @@ -66,8 +66,8 @@ def post(self, course_id): """ abort_url = f"{API_URL}/courses/{course_id}/students" data = request.get_json() - if len([key for key in data.keys() if key != "students"]) != 0: - return json_message("Incorrect data"), 400 + if any(key != "students" for key in data.keys()): + return json_message("Incorrect data field given"), 400 student_uids = data.get("students") abort_if_none_uid_student_uids_or_non_existant_course_id( course_id, student_uids @@ -104,7 +104,7 @@ def delete(self, course_id): abort_url = f"{API_URL}/courses/{str(course_id)}/students" data = request.get_json() if len([key for key in data.keys() if key != "students"]) != 0: - return json_message("Incorrect data"), 400 + return json_message("Incorrect data field given"), 400 student_uids = data.get("students") abort_if_none_uid_student_uids_or_non_existant_course_id( course_id, student_uids @@ -116,7 +116,7 @@ def delete(self, course_id): if student_relation: delete_abort_if_error(student_relation, abort_url) else: - return json_message("Not a student of the course"), 400 + return json_message(f"'{uid}' is not a student of the course"), 400 commit_abort_if_error(abort_url) response = json_message("Users were succesfully removed from the course") diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index e1f6af75..2b0e8431 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -52,12 +52,12 @@ def post(self, teacher_id=None): if "name" in request.json.keys(): name = request.json.get("name") if name is None or not isinstance(name, str): - return json_message("Name field does not have the correct type"), 400 + return json_message("The name field does not have the correct type"), 400 if "ufora_id" in request.json.keys(): ufora_id = request.json.get("ufora_id") if not isinstance(ufora_id, str): - return json_message("ufora_id field does not have the correct type"), 400 + return json_message("The ufora_id field does not have the correct type"), 400 req = request.json req["teacher"] = teacher_id diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index a836ff34..a42cfb2f 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -61,11 +61,6 @@ def models_to_dict(instances: List[DeclarativeMeta]) -> List[Dict[str, str]]: """ return [model_to_dict(instance) for instance in instances] - -def check_model_fields(model: DeclarativeMeta, data: dict[str, str]) -> bool: - """Checks if the data only contains fields of the model""" - return all(hasattr(model, key) for key in data.keys()) - def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): """ Filters the data to only contain the fields of the model. diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index a4e80795..e57cfeab 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -12,7 +12,7 @@ from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError from project.db_in import db -from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields, check_model_fields +from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields def delete_by_id_from_model( model: DeclarativeMeta, @@ -141,7 +141,7 @@ def query_selected_from_model(model: DeclarativeMeta, try: query: Query = model.query if filters: - if not check_model_fields(model, filters): + if not all(hasattr(model, key) for key in filters.keys()): return {"message": "Unknown parameter", "url": response_url}, 400 conditions: List[bool] = [] for key, value in filters.items(): From 6fd974bd94fa68b68328ae369001a7ce98e9d47b Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 10 Apr 2024 17:17:50 +0200 Subject: [PATCH 265/377] Generalizing some query parameter tests --- backend/tests/endpoints/conftest.py | 28 +++++++--- .../tests/endpoints/course/courses_test.py | 51 +++++++------------ backend/tests/endpoints/endpoint.py | 34 ++++++++++++- 3 files changed, 73 insertions(+), 40 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index c4cc7d69..41b33c02 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -3,7 +3,7 @@ import tempfile from datetime import datetime from zoneinfo import ZoneInfo -from typing import Tuple, List, Dict +from typing import Any import pytest from pytest import fixture, FixtureRequest @@ -19,14 +19,14 @@ ### AUTHENTICATION & AUTHORIZATION ### @fixture -def data_map(course: Course) -> Dict[str, any]: +def data_map(course: Course) -> dict[str, Any]: """Map an id to data""" return { "@course_id": course.course_id } @fixture -def auth_test(request: FixtureRequest, client: FlaskClient, data_map: Dict[str, any]) -> Tuple: +def auth_test(request: FixtureRequest, client: FlaskClient, data_map: dict[str, Any]) -> tuple: """Add concrete test data to auth""" # endpoint, method, token, allowed endpoint, method, *other = request.param @@ -36,10 +36,13 @@ def auth_test(request: FixtureRequest, client: FlaskClient, data_map: Dict[str, return endpoint, getattr(client, method), *other + + +### DATA FIELD TYPE ### @fixture def data_field_type_test( - request: FixtureRequest, client: FlaskClient, data_map: Dict[str, any] - ) -> Tuple[str, any, str, Dict[str, any]]: + request: FixtureRequest, client: FlaskClient, data_map: dict[str, Any] + ) -> tuple[str, Any, str, dict[str, Any]]: """Add concrete test data to the data_field tests""" endpoint, method, token, data = request.param @@ -56,6 +59,19 @@ def data_field_type_test( +### QUERY PARAMETER ### +@fixture +def query_parameter_test(request: FixtureRequest, client: FlaskClient, data_map: dict[str, Any]): + """Add concrete test data to the query_parameter tests""" + endpoint, method, token, wrong_parameter = request.param + + for key, value in data_map.items(): + endpoint = endpoint.replace(key, str(value)) + + return endpoint, getattr(client, method), token, wrong_parameter + + + ### USERS ### @fixture def student(session: Session) -> User: @@ -86,7 +102,7 @@ def admin_other(session: Session) -> User: ### COURSES ### @fixture -def courses(session: Session, teacher: User) -> List[Course]: +def courses(session: Session, teacher: User) -> list[Course]: """Return course entries""" courses = [Course(name=f"SEL{i}", teacher=teacher.uid) for i in range(1, 3)] session.add_all(courses) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index e8a7872e..a7b78bb3 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -1,13 +1,15 @@ """Tests the courses API endpoint""" from typing import Any +from dataclasses import fields from pytest import mark from flask.testing import FlaskClient from tests.endpoints.endpoint import ( TestEndpoint, authentication_tests, authorization_tests, - data_field_type_tests + data_field_type_tests, + query_parameter_tests ) from project.models.user import User from project.models.course import Course @@ -59,9 +61,9 @@ def test_authorization(self, auth_test: tuple[str, Any, str, bool]): - ### DATA ### + ### DATA FIELD TYPE ### # Test a data field by passing a list of values for which it should return bad request - data_fields = \ + data_field_type_tests = \ data_field_type_tests("/courses", "post", "teacher", {"name": "test", "ufora_id": "test"}, {"name": [None, 0], "ufora_id": [0]} @@ -87,13 +89,25 @@ def test_authorization(self, auth_test: tuple[str, Any, str, bool]): {"admin_uid": [None, "no_user", "admin_other"]} ) - @mark.parametrize("data_field_type_test", data_fields, indirect=True) + @mark.parametrize("data_field_type_test", data_field_type_tests, indirect=True) def test_data_fields(self, data_field_type_test: tuple[str, Any, str, dict[str, Any]]): """Test a data field typing""" super().data_field_type(data_field_type_test) + ### QUERY PARAMETER ### + # Test a query parameter, should return [] for wrong values + query_parameter_tests = \ + query_parameter_tests("/courses", "get", "student", [f.name for f in fields(Course)]) + + @mark.parametrize("query_parameter_test", query_parameter_tests, indirect=True) + def test_query_parameters(self, query_parameter_test: tuple[str, Any, str, bool]): + """Test a query parameter""" + super().query_parameter(query_parameter_test) + + + ### COURSES ### def test_get_courses(self, client: FlaskClient, courses: list[Course]): """Test getting all courses""" @@ -102,17 +116,6 @@ def test_get_courses(self, client: FlaskClient, courses: list[Course]): data = [course["name"] for course in response.json["data"]] assert all(course.name in data for course in courses) - def test_get_courses_wrong_parameter(self, client: FlaskClient): - """Test getting courses for a wrong parameter""" - response = client.get("/courses?parameter=0", headers = {"Authorization": "student"}) - assert response.status_code == 400 - - def test_get_courses_wrong_name(self, client: FlaskClient): - """Test getting courses for a wrong course name""" - response = client.get("/courses?name=no_name", headers = {"Authorization": "student"}) - assert response.status_code == 200 - assert response.json["data"] == [] - def test_get_courses_name(self, client: FlaskClient, course: Course): """Test getting courses for a given course name""" response = client.get( @@ -122,15 +125,6 @@ def test_get_courses_name(self, client: FlaskClient, course: Course): assert response.status_code == 200 assert response.json["data"][0]["name"] == course.name - def test_get_courses_wrong_ufora_id(self, client: FlaskClient): - """Test getting courses for a wrong ufora_id""" - response = client.get( - "/courses?ufora_id=no_ufora_id", - headers = {"Authorization": "student"} - ) - assert response.status_code == 200 - assert response.json["data"] == [] - def test_get_courses_ufora_id(self, client: FlaskClient, course: Course): """Test getting courses for a given ufora_id""" response = client.get( @@ -140,15 +134,6 @@ def test_get_courses_ufora_id(self, client: FlaskClient, course: Course): assert response.status_code == 200 assert response.json["data"][0]["ufora_id"] == course.ufora_id - def test_get_courses_wrong_teacher(self, client: FlaskClient): - """Test getting courses for a wrong teacher""" - response = client.get( - "/courses?teacher=no_teacher", - headers = {"Authorization": "student"} - ) - assert response.status_code == 200 - assert response.json["data"] == [] - def test_get_courses_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given teacher""" response = client.get( diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index c3d1bb25..b223e8d8 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -56,6 +56,28 @@ def data_field_type_tests( return tests +def query_parameter_tests( + endpoint: str, method: str, token: str, parameters: list[str] + ) -> list[Any]: + """Transform the format to single query_parameter tests""" + tests = [] + + # Test with an incorrect parameter + new_endpoint = endpoint + "?parameter=0" + tests.append(param( + (new_endpoint, method, token, True), + id = f"{new_endpoint} {method.upper()} {token} (parameter 0 400)" + )) + + for parameter in parameters: + new_endpoint = endpoint + f"?{parameter}=0" + tests.append(param( + (new_endpoint, method, token, False), + id = f"{new_endpoint} {method.upper()} {token} ({parameter} 0 200&[])" + )) + + return tests + class TestEndpoint: """Base class for endpoint tests""" @@ -87,5 +109,15 @@ def data_field_type(self, test: tuple[str, Any, str, dict[str, Any]]): endpoint, method, token, data = test response = method(endpoint, headers = {"Authorization": token}, json = data) - print("TESTING", response.status_code) assert response.status_code == 400 + + def query_parameter(self, test: tuple[str, Any, str, bool]): + """Test the query parameter""" + + endpoint, method, token, wrong_parameter = test + + response = method(endpoint, headers = {"Authorization": token}) + assert wrong_parameter == (response.status_code == 400) + + if not wrong_parameter: + assert response.json["data"] == [] From fe87af6db426e365aedfe27de5720f3ba29f66e9 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 10 Apr 2024 20:19:59 +0200 Subject: [PATCH 266/377] Fixing linter --- .../endpoints/courses/course_details.py | 28 +++--------------- backend/project/endpoints/courses/courses.py | 17 +++-------- .../endpoints/courses/courses_utils.py | 29 ++++++++++++++++++- 3 files changed, 36 insertions(+), 38 deletions(-) diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 4e3cca0a..4c3be3e8 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -18,10 +18,9 @@ from project.models.course_relation import CourseAdmin, CourseStudent from project.db_in import db -from project.endpoints.courses.courses_utils import execute_query_abort_if_db_error, json_message +from project.endpoints.courses.courses_utils import check_data from project.utils.query_agent import delete_by_id_from_model, patch_by_id_from_model from project.utils.authentication import login_required, authorize_teacher_of_course -from project.models.user import User, Role load_dotenv() API_URL = getenv("API_HOST") @@ -108,28 +107,9 @@ def patch(self, course_id): This function will update the course with course_id """ - if not all(hasattr(Course, key) for key in request.json.keys()): - return json_message("Incorrect data"), 400 - - if "name" in request.json.keys(): - name = request.json.get("name") - if name is None or not isinstance(name, str): - return json_message("The name field does not have the correct type"), 400 - - if "ufora_id" in request.json.keys(): - ufora_id = request.json.get("ufora_id") - if not isinstance(ufora_id, str): - return json_message("The ufora_id field does not have the correct type"), 400 - - if "teacher" in request.json.keys(): - teacher = request.json.get("teacher") - if teacher is None or not isinstance(teacher, str): - return json_message("The teacher field does not have the correct type"), 400 - user = execute_query_abort_if_db_error(User.query.filter_by(uid=teacher), RESPONSE_URL) - if user.role != Role.TEACHER: - return json_message( - "The user given in the teacher field does not have the correct role" - ), 400 + message, status = check_data(request.json, False) + if status != 200: + return message, status return patch_by_id_from_model( Course, diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index 2b0e8431..b550b60f 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -15,7 +15,7 @@ from project.models.course import Course from project.utils.query_agent import query_selected_from_model, insert_into_model from project.utils.authentication import login_required, authorize_teacher -from project.endpoints.courses.courses_utils import json_message +from project.endpoints.courses.courses_utils import check_data load_dotenv() API_URL = getenv("API_HOST") @@ -46,18 +46,9 @@ def post(self, teacher_id=None): if the body of the post contains a name and uid is an admin or teacher """ - if not all(hasattr(Course, key) for key in request.json.keys()): - return json_message("The data contains an incorrect field"), 400 - - if "name" in request.json.keys(): - name = request.json.get("name") - if name is None or not isinstance(name, str): - return json_message("The name field does not have the correct type"), 400 - - if "ufora_id" in request.json.keys(): - ufora_id = request.json.get("ufora_id") - if not isinstance(ufora_id, str): - return json_message("The ufora_id field does not have the correct type"), 400 + message, status = check_data(request.json, False) + if status != 200: + return message, status req = request.json req["teacher"] = teacher_id diff --git a/backend/project/endpoints/courses/courses_utils.py b/backend/project/endpoints/courses/courses_utils.py index 57e9480c..a6ea3fc8 100644 --- a/backend/project/endpoints/courses/courses_utils.py +++ b/backend/project/endpoints/courses/courses_utils.py @@ -12,7 +12,7 @@ from project.db_in import db from project.models.course_relation import CourseAdmin -from project.models.user import User +from project.models.user import User, Role from project.models.course import Course load_dotenv() @@ -216,3 +216,30 @@ def get_course_abort_if_not_found(course_id, url=f"{API_URL}/courses"): abort(404, description=response) return course + +def check_data(data: dict[str, str], check_teacher: bool): + """Check the data""" + if not all(hasattr(Course, key) for key in data.keys()): + return json_message("The data contains an incorrect field"), 400 + + if "name" in data.keys(): + name = data.get("name") + if name is None or not isinstance(name, str): + return json_message("The name field does not have the correct type"), 400 + + if "ufora_id" in data.keys(): + ufora_id = data.get("ufora_id") + if not isinstance(ufora_id, str): + return json_message("The ufora_id field does not have the correct type"), 400 + + if check_teacher and "teacher" in data.keys(): + teacher = data.get("teacher") + if teacher is None or not isinstance(teacher, str): + return json_message("The teacher field does not have the correct type"), 400 + user = execute_query_abort_if_db_error(User.query.filter_by(uid=teacher), RESPONSE_URL) + if user.role != Role.TEACHER: + return json_message( + "The user given in the teacher field does not have the correct role" + ), 400 + + return json_message("ok"), 200 From 2367f22346a24a2623619a5e26fd73a233362678 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 10 Apr 2024 20:23:17 +0200 Subject: [PATCH 267/377] Whoops --- backend/project/endpoints/courses/course_details.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/courses/course_details.py b/backend/project/endpoints/courses/course_details.py index 4c3be3e8..2fad705c 100644 --- a/backend/project/endpoints/courses/course_details.py +++ b/backend/project/endpoints/courses/course_details.py @@ -107,7 +107,7 @@ def patch(self, course_id): This function will update the course with course_id """ - message, status = check_data(request.json, False) + message, status = check_data(request.json, True) if status != 200: return message, status From 4f2a9df75c1e7ea9c20744e452eb079f2cfbb70c Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Wed, 10 Apr 2024 22:07:18 +0200 Subject: [PATCH 268/377] Making authorization tests more explicit --- .../tests/endpoints/course/courses_test.py | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index a7b78bb3..1c9f15a9 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -35,24 +35,36 @@ def test_authentication(self, auth_test: tuple[str, Any]): ### AUTHORIZATION ### # Who can access what authorization_tests = \ - authorization_tests("/courses", "get", ["student", "teacher", "admin"], []) + \ - authorization_tests("/courses", "post", ["teacher"], ["student", "admin"]) + \ + authorization_tests("/courses", "get", + ["student", "student_other", "teacher", "teacher_other", "admin", "admin_other"], + []) + \ + authorization_tests("/courses", "post", + ["teacher", "teacher_other"], + ["student", "student_other", "admin", "admin_other"]) + \ authorization_tests("/courses/@course_id", "patch", - ["teacher"], ["student", "teacher_other", "admin"]) + \ + ["teacher"], + ["student", "student_other", "teacher_other", "admin", "admin_other"]) + \ authorization_tests("/courses/@course_id", "delete", - ["teacher"], ["student", "teacher_other", "admin"]) + \ + ["teacher"], + ["student", "student_other", "teacher_other", "admin", "admin_other"]) + \ authorization_tests("/courses/@course_id/students", "get", - ["student", "teacher", "admin"], []) + \ + ["student", "student_other", "teacher", "teacher_other", "admin", "admin_other"], + []) + \ authorization_tests("/courses/@course_id/students", "post", - ["teacher", "admin"], ["student", "teacher_other", "admin_other"]) + \ + ["teacher", "admin"], + ["student", "student_other", "teacher_other", "admin_other"]) + \ authorization_tests("/courses/@course_id/students", "delete", - ["teacher", "admin"], ["student", "teacher_other", "admin_other"]) + \ + ["teacher", "admin"], + ["student", "student_other", "teacher_other", "admin_other"]) + \ authorization_tests("/courses/@course_id/admins", "get", - ["teacher", "admin"], ["student", "teacher_other", "admin_other"]) + \ + ["teacher", "admin"], + ["student", "student_other", "teacher_other", "admin_other"]) + \ authorization_tests("/courses/@course_id/admins", "post", - ["teacher"], ["student", "admin"]) + \ + ["teacher"], + ["student", "student_other", "teacher_other", "admin", "admin_other"]) + \ authorization_tests("/courses/@course_id/admins", "delete", - ["teacher"], ["student", "teacher_other", "admin"]) + ["teacher"], + ["student", "student_other", "teacher_other", "admin", "admin_other"]) @mark.parametrize("auth_test", authorization_tests, indirect=True) def test_authorization(self, auth_test: tuple[str, Any, str, bool]): From 5deefc6ab42b27897090c7bb6e6a156f3f28ff78 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 13 Apr 2024 14:40:48 +0200 Subject: [PATCH 269/377] registering composite types (#197) --- backend/project/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 4a61535d..018848d6 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -4,6 +4,7 @@ from flask import Flask from flask_cors import CORS +from sqlalchemy_utils import register_composites from .db_in import db from .endpoints.index.index import index_bp from .endpoints.users import users_bp @@ -45,5 +46,9 @@ def create_app_with_db(db_uri: str): app.config["SQLALCHEMY_DATABASE_URI"] = db_uri app.config["UPLOAD_FOLDER"] = "/" db.init_app(app) + with app.app_context(): + # Getting a connection from the scoped session + connection = db.session.connection() + register_composites(connection) CORS(app) return app From 8ca72d20b756c0ce18ac682bc3b8c17e91acbe19 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 13 Apr 2024 15:48:05 +0200 Subject: [PATCH 270/377] Added the runner used to test submissions as an optional field of project table (#199) * Fix #198 * added possibility for custom runner * fixed enum type name --- backend/db_construct.sql | 2 ++ backend/project/models/project.py | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index 347354fd..cd35fe3f 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,6 +1,7 @@ CREATE TYPE role AS ENUM ('STUDENT', 'TEACHER', 'ADMIN'); CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); +CREATE TYPE runner AS ENUM ('PYTHON', 'GENERAL', 'CUSTOM'); CREATE TABLE users ( uid VARCHAR(255), @@ -52,6 +53,7 @@ CREATE TABLE projects ( visible_for_students BOOLEAN NOT NULL, archived BOOLEAN NOT NULL, regex_expressions VARCHAR(50)[], + runner runner, PRIMARY KEY(project_id), CONSTRAINT fk_course FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE ); diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 624f9ed0..75e425e6 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -1,10 +1,27 @@ """Project model""" from dataclasses import dataclass -from sqlalchemy import ARRAY, Boolean, Column, DateTime, ForeignKey, Integer, String, Text +from enum import Enum +from sqlalchemy import ( + ARRAY, + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Text, + Enum as EnumField) + from sqlalchemy_utils import CompositeType from project.db_in import db +class Runner(str, Enum): + """Enum for Runner""" + PYTHON = 'PYTHON' + GENERAL = 'GENERAL' + CUSTOM = 'CUSTOM' + @dataclass class Project(db.Model): # pylint: disable=too-many-instance-attributes """This class describes the projects table, @@ -38,4 +55,7 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) visible_for_students: bool = Column(Boolean, nullable=False) archived: bool = Column(Boolean, nullable=False) + runner: Runner = Column( + EnumField(Runner, name="runner"), + nullable=False) regex_expressions: list[str] = Column(ARRAY(String(50))) From 36ed720b4f5086da8e4a027dec7ab36adc16dba1 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sun, 14 Apr 2024 12:12:33 +0200 Subject: [PATCH 271/377] Running evaluator in synchron when runner is set (#203) * Fix #200 * created executor * fixed tests * resolved linting * linting * fix wrong evaluator in test * adjusted runner to support test --- backend/project/__init__.py | 2 + .../endpoints/projects/endpoint_parser.py | 2 + .../project/endpoints/projects/projects.py | 10 +++-- backend/project/endpoints/submissions.py | 19 ++++++-- backend/project/executor.py | 9 ++++ .../project/utils/submissions/evaluator.py | 41 ++++++++++++++++-- backend/requirements.txt | 1 + .../submission_evaluators/python_test.py | 6 +-- .../python/tc_1/assignment/run_test.zip | Bin 0 -> 184 bytes 9 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 backend/project/executor.py create mode 100644 backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.zip diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 018848d6..2d454467 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -5,6 +5,7 @@ from flask import Flask from flask_cors import CORS from sqlalchemy_utils import register_composites +from .executor import executor from .db_in import db from .endpoints.index.index import index_bp from .endpoints.users import users_bp @@ -22,6 +23,7 @@ def create_app(): """ app = Flask(__name__) + executor.init_app(app) app.register_blueprint(index_bp) app.register_blueprint(users_bp) app.register_blueprint(courses_bp) diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index f4ab93ea..26e23b8a 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -31,6 +31,8 @@ location="form" ) +parser.add_argument("runner", type=str, help='Projects runner', location="form") + def parse_project_params(): """ diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 01873379..dfc04895 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -11,14 +11,14 @@ from project.db_in import db -from project.models.project import Project +from project.models.project import Project, Runner from project.utils.query_agent import query_selected_from_model, create_model_instance from project.utils.authentication import authorize_teacher from project.endpoints.projects.endpoint_parser import parse_project_params API_URL = os.getenv('API_HOST') -UPLOAD_FOLDER = os.getenv('UPLOAD_URL') +UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER") class ProjectsEndpoint(Resource): @@ -43,7 +43,6 @@ def get(self, teacher_id=None): filters=request.args ) - @authorize_teacher def post(self, teacher_id=None): """ Post functionality for project @@ -84,6 +83,11 @@ def post(self, teacher_id=None): file.save(file_path) with zipfile.ZipFile(file_path) as upload_zip: upload_zip.extractall(project_upload_directory) + + if not new_project.runner and \ + os.path.exists(os.path.join(project_upload_directory, "Dockerfile")): + + new_project.runner = Runner.CUSTOM except zipfile.BadZipfile: os.remove(os.path.join(project_upload_directory, filename)) db.session.rollback() diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index e4d204e7..5b3412f0 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -8,6 +8,7 @@ from flask import Blueprint, request from flask_restful import Resource from sqlalchemy import exc +from project.executor import executor from project.db_in import db from project.models.submission import Submission, SubmissionStatus from project.models.project import Project @@ -20,6 +21,8 @@ authorize_submissions_request, authorize_grader, \ authorize_student_submission, authorize_submission_author +from project.utils.submissions.evaluator import run_evaluator + load_dotenv() API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") @@ -132,13 +135,23 @@ def post(self) -> dict[str, any]: "submissions", str(submission.submission_id)) try: makedirs(submission.submission_path, exist_ok=True) + input_folder = path.join(submission.submission_path, "submission") + makedirs(input_folder, exist_ok=True) for file in files: - file.save(path.join(submission.submission_path, file.filename)) - session.commit() + file.save(path.join(input_folder, file.filename)) except OSError: rmtree(submission.submission_path) session.rollback() + if project.runner: + submission.submission_status = SubmissionStatus.RUNNING + executor.submit( + run_evaluator, + submission, + path.join(UPLOAD_FOLDER, str(project.project_id)), + project.runner.value, + False) + data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = { @@ -149,7 +162,7 @@ def post(self) -> dict[str, any]: "submission_time": submission.submission_time, "submission_status": submission.submission_status } - return data, 201 + return data, 202 except exc.SQLAlchemyError: session.rollback() diff --git a/backend/project/executor.py b/backend/project/executor.py new file mode 100644 index 00000000..12405d1e --- /dev/null +++ b/backend/project/executor.py @@ -0,0 +1,9 @@ +""" +This file is used to create an instance of the Executor class from the flask_executor package. +This instance is used to create a background task that will run the evaluator. +This is done to prevent the server from being blocked while the model is being trained. +""" + +from flask_executor import Executor + +executor = Executor() diff --git a/backend/project/utils/submissions/evaluator.py b/backend/project/utils/submissions/evaluator.py index e2ebca00..da81aefc 100644 --- a/backend/project/utils/submissions/evaluator.py +++ b/backend/project/utils/submissions/evaluator.py @@ -8,16 +8,18 @@ exit code is returned. The output of the evaluator is written to a log file in the submission output folder. """ -from os import path +from os import path, makedirs import docker +from sqlalchemy.exc import SQLAlchemyError +from project.db_in import db from project.models.submission import Submission DOCKER_IMAGE_MAPPER = { - "python": path.join(path.dirname(__file__), "evaluators", "python"), + "PYTHON": path.join(path.dirname(__file__), "evaluators", "python"), } -def evaluate(submission: Submission, project_path: str, evaluator: str) -> int: +def evaluate(submission: Submission, project_path: str, evaluator: str, is_late: bool) -> int: """ Evaluate a submission using the evaluator. @@ -51,6 +53,7 @@ def evaluate(submission: Submission, project_path: str, evaluator: str) -> int: submission_solution_path) submission_output_path = path.join(submission_path, "output") + makedirs(submission_output_path, exist_ok=True) test_output_path = path.join(submission_output_path, "test_output.log") exit_code = container.wait() @@ -62,6 +65,38 @@ def evaluate(submission: Submission, project_path: str, evaluator: str) -> int: return exit_code['StatusCode'] +def run_evaluator(submission: Submission, project_path: str, evaluator: str, is_late: bool) -> int: + """ + Run the evaluator for the submission. + + Args: + submission (Submission): The submission to evaluate. + project_path (str): The path to the project. + evaluator (str): The evaluator to use. + is_late (bool): Whether the submission is late. + + Returns: + int: The exit code of the evaluator. + """ + status_code = evaluate(submission, project_path, evaluator, is_late) + + if not is_late: + if status_code == 0: + submission.submission_status = 'SUCCESS' + else: + submission.submission_status = 'FAIL' + else: + submission.submission_status = 'LATE' + + try: + db.session.merge(submission) + db.session.commit() + except SQLAlchemyError: + pass + + return status_code + + def create_and_run_evaluator(docker_image: str, submission_id: int, project_path: str, diff --git a/backend/requirements.txt b/backend/requirements.txt index 8733be9e..9b016df4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,4 @@ SQLAlchemy~=2.0.27 requests>=2.31.0 waitress flask_swagger_ui +flask_executor diff --git a/backend/tests/utils/submission_evaluators/python_test.py b/backend/tests/utils/submission_evaluators/python_test.py index 8db67349..0d8b548c 100644 --- a/backend/tests/utils/submission_evaluators/python_test.py +++ b/backend/tests/utils/submission_evaluators/python_test.py @@ -23,7 +23,7 @@ def evaluate_python(submission_root, project_path_succes): project_id = 1 submission = Submission(submission_id=1, project_id=project_id) submission.submission_path = create_submission_folder(submission_root, project_id) - return evaluate(submission, project_path_succes, "python"), submission.submission_path + return evaluate(submission, project_path_succes, "PYTHON", False), submission.submission_path def prep_submission_and_clear_after_py(tc_folder: str) -> tuple[Submission, Project]: """ @@ -64,13 +64,13 @@ def test_logs_output(evaluate_python): def test_with_dependency(): """Test whether the evaluator works with a dependency.""" submission, project = prep_submission_and_clear_after_py("tc_2") - exit_code = evaluate(submission, project, "python") + exit_code = evaluate(submission, project, "PYTHON", False) cleanup_after_test(submission.submission_path) assert exit_code == 0 def test_dependency_manifest(): """Test whether the evaluator works with a dependency manifest.""" submission, project = prep_submission_and_clear_after_py("tc_3") - exit_code = evaluate(submission, project, "python") + exit_code = evaluate(submission, project, "PYTHON", False) cleanup_after_test(submission.submission_path) assert exit_code != 0 diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.zip b/backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.zip new file mode 100644 index 0000000000000000000000000000000000000000..8d1ad5d86ede7f51c979b2291880c946429049cc GIT binary patch literal 184 zcmWIWW@h1H0D%dUJ0ld&HH*mr*&xgf#6_if@g=FnC3?jfsmU4n3Q8WSIXU?{3gP)h zIVp-tTwJLYnI#Ga0p5&E_6)d;Q2`nR0t#Rf#ZWG&AR~hWgMXLkw^?1c7@?}*RDd@t P8;HXQgt0){6)Xb)*T5iZ literal 0 HcmV?d00001 From 710526e713bd9bc977c744db83a77a48192ed2a5 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sun, 14 Apr 2024 14:05:07 +0200 Subject: [PATCH 272/377] Check if submission is LATE (#196) * checking for if deadline is on time * Fix #82 * removed redundant print statement * authenticate endpoint * setting submission status when no runner * fixed inverted boolean --- backend/project/endpoints/submissions.py | 17 +++++++++++++---- .../endpoints/submissions/submission_detail.py | 0 2 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 backend/project/endpoints/submissions/submission_detail.py diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 5b3412f0..528b0e94 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -3,6 +3,7 @@ from urllib.parse import urljoin from datetime import datetime from os import getenv, path, makedirs +from zoneinfo import ZoneInfo from shutil import rmtree from dotenv import load_dotenv from flask import Blueprint, request @@ -28,6 +29,8 @@ UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") BASE_URL = urljoin(f"{API_HOST}/", "/submissions") +TIMEZONE = getenv("TIMEZONE", "GMT") + submissions_bp = Blueprint("submissions", __name__) class SubmissionsEndpoint(Resource): @@ -108,10 +111,7 @@ def post(self) -> dict[str, any]: submission.project_id = int(project_id) # Submission time - submission.submission_time = datetime.now() - - # Submission status - submission.submission_status = SubmissionStatus.RUNNING + submission.submission_time = datetime.now(ZoneInfo(TIMEZONE)) # Submission files submission.submission_path = "" # Must be set on creation @@ -126,6 +126,12 @@ def post(self) -> dict[str, any]: f"(required files={','.join(project.regex_expressions)})" return data, 400 + deadlines = project.deadlines + is_late = True + for deadline in deadlines: + if submission.submission_time < deadline.deadline: + is_late = False + # Submission_id needed for the file location session.add(submission) session.commit() @@ -151,6 +157,9 @@ def post(self) -> dict[str, any]: path.join(UPLOAD_FOLDER, str(project.project_id)), project.runner.value, False) + else: + submission.submission_status = SubmissionStatus.LATE if is_late \ + else SubmissionStatus.SUCCESS data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") diff --git a/backend/project/endpoints/submissions/submission_detail.py b/backend/project/endpoints/submissions/submission_detail.py new file mode 100644 index 00000000..e69de29b From 33b2165a2336830d6cae37476ccca05594f14e93 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sun, 14 Apr 2024 20:35:09 +0200 Subject: [PATCH 273/377] router fix (#205) --- frontend/src/App.tsx | 37 ++++++++++++----------- frontend/src/components/Header/Layout.tsx | 15 +++++++++ 2 files changed, 35 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/Header/Layout.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index eb541cd4..6ab48f5d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,27 +1,30 @@ -import { BrowserRouter,Route,Routes } from "react-router-dom"; -import { Header } from "./components/Header/Header"; +import { Route,RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; +import Layout from "./components/Header/Layout"; import Home from "./pages/home/Home"; import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; +const router = createBrowserRouter( + createRoutesFromElements( + <Route path="/" element={<Layout />}> + <Route index element={<Home />} /> + <Route path=":lang" element={<LanguagePath/>}> + <Route path="home" element={<Home />} /> + <Route path="project" > + <Route path=":projectId" element={<ProjectView />}/> + </Route> + </Route> + </Route> + ) +); + /** * This component is the main application component that will be rendered by the ReactDOM. * @returns - The main application component */ -function App(): JSX.Element { +export default function App(): React.JSX.Element { return ( - <BrowserRouter> - <Header /> - <Routes> - <Route index element={<Home />} /> - <Route path=":lang" element={<LanguagePath/>}> - <Route path="home" element={<Home />} /> - <Route path="project" > - <Route path=":projectId" element={<ProjectView />}/> - </Route> - </Route> - </Routes> - </BrowserRouter> + <RouterProvider router={router}> + </RouterProvider> ); -} -export default App; \ No newline at end of file +} \ No newline at end of file diff --git a/frontend/src/components/Header/Layout.tsx b/frontend/src/components/Header/Layout.tsx new file mode 100644 index 00000000..e63283c8 --- /dev/null +++ b/frontend/src/components/Header/Layout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from "react-router-dom"; +import { Header } from "./Header.tsx"; + +/** + * Basic layout component that will be used on all routes. + * @returns The Layout component + */ +export default function Layout(): JSX.Element { + return ( + <> + <Header /> + <Outlet /> + </> + ); +} \ No newline at end of file From 1e2a9d6a2190193ef983c70cba80bcc62d39db5a Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Mon, 15 Apr 2024 21:07:34 +0200 Subject: [PATCH 274/377] Added functionality to download submissions (#206) * Fix #189 * fixed linter * fixed linting --- backend/project/__init__.py | 2 +- .../endpoints/projects/project_endpoint.py | 7 + .../projects/project_submissions_download.py | 97 ++++ .../submissions/submission_config.py | 22 + .../submissions/submission_detail.py | 141 ++++++ .../submissions/submission_download.py | 59 +++ .../{ => submissions}/submissions.py | 479 +++++++----------- 7 files changed, 504 insertions(+), 303 deletions(-) create mode 100644 backend/project/endpoints/projects/project_submissions_download.py create mode 100644 backend/project/endpoints/submissions/submission_config.py create mode 100644 backend/project/endpoints/submissions/submission_download.py rename backend/project/endpoints/{ => submissions}/submissions.py (53%) diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 2d454467..fe9be2e4 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -11,7 +11,7 @@ from .endpoints.users import users_bp from .endpoints.courses.courses_config import courses_bp from .endpoints.projects.project_endpoint import project_bp -from .endpoints.submissions import submissions_bp +from .endpoints.submissions.submission_config import submissions_bp from .endpoints.courses.join_codes.join_codes_config import join_codes_bp from .endpoints.docs.docs_endpoint import swagger_ui_blueprint diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index a48b615d..0aede0ff 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -8,6 +8,8 @@ from project.endpoints.projects.projects import ProjectsEndpoint from project.endpoints.projects.project_detail import ProjectDetail from project.endpoints.projects.project_assignment_file import ProjectAssignmentFiles +from project.endpoints.projects.project_submissions_download import SubmissionDownload + project_bp = Blueprint('project_endpoint', __name__) @@ -25,3 +27,8 @@ '/projects/<int:project_id>/assignment', view_func=ProjectAssignmentFiles.as_view('project_assignment') ) + +project_bp.add_url_rule( + '/projects/<int:project_id>/submissions-download', + view_func=SubmissionDownload.as_view('project_submissions') +) diff --git a/backend/project/endpoints/projects/project_submissions_download.py b/backend/project/endpoints/projects/project_submissions_download.py new file mode 100644 index 00000000..d59a8ca2 --- /dev/null +++ b/backend/project/endpoints/projects/project_submissions_download.py @@ -0,0 +1,97 @@ +""" +This module contains the implementation of the endpoint that +allows teachers to download all relevant submissions for a project. +""" + +from os import getenv, path, walk +from urllib.parse import urljoin +import zipfile +import io +from flask_restful import Resource +from flask import Response, stream_with_context +from sqlalchemy import func +from sqlalchemy.exc import SQLAlchemyError +from project.models.project import Project +from project.models.submission import Submission +from project.db_in import db + +API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/projects") + +class SubmissionDownload(Resource): + """ + Resource to download all submissions for a project. + """ + def get(self, project_id: int): + """ + Download all submissions for a project as a zip file. + """ + + try: + project = Project.query.get(project_id) + except SQLAlchemyError: + return {"message": "Internal server error"}, 500 + + if project is None: + return { + "message": f"Project (project_id={project_id}) not found", + "url": BASE_URL}, 404 + + # Define a subquery to find the latest submission times for each user + latest_submissions = db.session.query( + Submission.uid, + func.max(Submission.submission_time).label('max_time') + ).filter( + Submission.project_id == project_id, + Submission.submission_status != 'LATE' + ).group_by( + Submission.uid + ).subquery() + + # Use the subquery to fetch the actual submissions + submissions = db.session.query(Submission).join( + latest_submissions, + (Submission.uid == latest_submissions.c.uid) & + (Submission.submission_time == latest_submissions.c.max_time) + ).all() + + if not submissions: + return {"message": "No submissions found", "url": BASE_URL}, 404 + + def zip_directory_stream(): + with io.BytesIO() as memory_file: + with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf: + for submission in submissions: + submission_path = path.join( + UPLOAD_FOLDER, + str(submission.project_id), + "submissions", + str(submission.submission_id)) + + # Directory in the zip should use uid instead of submission_id + zip_dir_path = path.join( + "submissions", + str(submission.uid)) + + # Walk through each directory and file, maintaining the structure + if path.exists(submission_path) and path.isdir(submission_path): + for dirname, _, files in walk(submission_path): + arcname_dir = dirname.replace(submission_path, zip_dir_path) + zf.write(dirname, arcname=arcname_dir) + for filename in files: + file_path = path.join(dirname, filename) + arcname_file = file_path.replace(submission_path, zip_dir_path) + zf.write(file_path, arcname=arcname_file) + + memory_file.seek(0) + + while True: + data = memory_file.read(4096) + if not data: + break + yield data + + response = Response(stream_with_context(zip_directory_stream()), mimetype='application/zip') + response.headers['Content-Disposition'] = 'attachment; filename="submissions.zip"' + return response diff --git a/backend/project/endpoints/submissions/submission_config.py b/backend/project/endpoints/submissions/submission_config.py new file mode 100644 index 00000000..c17395ad --- /dev/null +++ b/backend/project/endpoints/submissions/submission_config.py @@ -0,0 +1,22 @@ +""" +This module is responsible for creating the submissions blueprint and +adding the submission endpoints to it. +""" + +from flask import Blueprint +from project.endpoints.submissions.submissions import SubmissionsEndpoint +from project.endpoints.submissions.submission_detail import SubmissionEndpoint +from project.endpoints.submissions.submission_download import SubmissionDownload + +submissions_bp = Blueprint("submissions", __name__) + + +submissions_bp.add_url_rule("/submissions", view_func=SubmissionsEndpoint.as_view("submissions")) +submissions_bp.add_url_rule( + "/submissions/<int:submission_id>", + view_func=SubmissionEndpoint.as_view("submission") +) +submissions_bp.add_url_rule( + "/submissions/<int:submission_id>/download", + view_func=SubmissionDownload.as_view("submission_download") +) diff --git a/backend/project/endpoints/submissions/submission_detail.py b/backend/project/endpoints/submissions/submission_detail.py index e69de29b..ad7e2c0a 100644 --- a/backend/project/endpoints/submissions/submission_detail.py +++ b/backend/project/endpoints/submissions/submission_detail.py @@ -0,0 +1,141 @@ +""" +This module contains the API endpoint for the submission detail +""" + +from os import getenv +from urllib.parse import urljoin +from flask import request +from flask_restful import Resource +from sqlalchemy import exc +from project.db_in import db +from project.models.submission import Submission +from project.utils.query_agent import delete_by_id_from_model +from project.utils.authentication import ( + authorize_submission_request, + authorize_grader, + authorize_submission_author) + +API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/submissions") + +class SubmissionEndpoint(Resource): + """API endpoint for the submission""" + + @authorize_submission_request + def get(self, submission_id: int) -> dict[str, any]: + """Get the submission given an submission ID + + Args: + submission_id (int): Submission ID + + Returns: + dict[str, any]: The submission + """ + + data = { + "url": urljoin(f"{BASE_URL}/", str(submission_id)) + } + try: + with db.session() as session: + submission = session.get(Submission, submission_id) + if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 + + return { + "data": { + "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), + "uid": urljoin(f"{API_HOST}/", f"/users/{str(submission.uid)}"), + "project_id": urljoin( + f"{API_HOST}/", + f"/projects/{str(submission.project_id)}"), + "grading": submission.grading, + "submission_time": submission.submission_time, + "submission_status": submission.submission_status + }, + "message": "Successfully fetched the submission", + "url": urljoin(f"{BASE_URL}/", str(submission.submission_id)) + }, 200 + + except exc.SQLAlchemyError: + data["message"] = \ + f"An error occurred while fetching the submission (submission_id={submission_id})" + return data, 500 + + @authorize_grader + def patch(self, submission_id:int) -> dict[str, any]: + """Update some fields of a submission given a submission ID + + Args: + submission_id (int): Submission ID + + Returns: + dict[str, any]: A message + """ + + data = { + "url": urljoin(f"{BASE_URL}/", str(submission_id)) + } + try: + with db.session() as session: + # Get the submission + submission = session.get(Submission, submission_id) + if submission is None: + data["url"] = urljoin(f"{API_HOST}/", "/submissions") + data["message"] = f"Submission (submission_id={submission_id}) not found" + return data, 404 + + # Update the grading field + grading = request.form.get("grading") + if grading is not None: + try: + grading_float = float(grading) + if 0 <= grading_float <= 20: + submission.grading = grading_float + else: + data["message"] = "Invalid grading (grading=0-20)" + return data, 400 + except ValueError: + data["message"] = "Invalid grading (not a valid float)" + return data, 400 + + # Save the submission + session.commit() + + data["message"] = f"Submission (submission_id={submission_id}) patched" + data["url"] = urljoin(f"{BASE_URL}/", str(submission.submission_id)) + data["data"] = { + "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), + "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), + "grading": submission.grading, + "time": submission.submission_time, + "status": submission.submission_status + } + return data, 200 + + except exc.SQLAlchemyError: + session.rollback() + data["message"] = \ + f"An error occurred while patching submission (submission_id={submission_id})" + return data, 500 + + @authorize_submission_author + def delete(self, submission_id: int) -> dict[str, any]: + """Delete a submission given a submission ID + + Args: + submission_id (int): Submission ID + + Returns: + dict[str, any]: A message + """ + + return delete_by_id_from_model( + Submission, + "submission_id", + submission_id, + BASE_URL + ) diff --git a/backend/project/endpoints/submissions/submission_download.py b/backend/project/endpoints/submissions/submission_download.py new file mode 100644 index 00000000..cc0b3c8e --- /dev/null +++ b/backend/project/endpoints/submissions/submission_download.py @@ -0,0 +1,59 @@ +""" +This module contains the endpoint for downloading a submission. +""" + +import zipfile +import io +from os import getenv, path, walk +from urllib.parse import urljoin +from flask import Response, stream_with_context +from flask_restful import Resource +from project.models.submission import Submission + +API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/submissions") + +class SubmissionDownload(Resource): + """ + Resource to download a submission. + """ + def get(self, submission_id: int): + """ + Download a submission as a zip file. + """ + submission = Submission.query.get(submission_id) + if submission is None: + return { + "message": f"Submission (submission_id={submission_id}) not found", + "url": BASE_URL}, 404 + + submission_path = path.join( + UPLOAD_FOLDER, + str(submission.project_id), + "submissions", + str(submission_id)) + + if not path.exists(submission_path) or not path.isdir(submission_path): + return {"message": "Submission directory not found", "url": BASE_URL}, 404 + + def zip_directory_stream(): + with io.BytesIO() as memory_file: + with zipfile.ZipFile(memory_file, 'w', zipfile.ZIP_DEFLATED) as zf: + + for dirname, _, files in walk(submission_path): + zf.write(dirname, path.relpath(dirname, start=submission_path)) + for filename in files: + file_path = path.join(dirname, filename) + zf.write(file_path, path.relpath(file_path, start=submission_path)) + + memory_file.seek(0) + data = memory_file.read(4096) + while data: + yield data + data = memory_file.read(4096) + + response = Response(stream_with_context(zip_directory_stream()), mimetype='application/zip') + response.headers['Content-Disposition'] = \ + f'attachment; filename="submission_{submission_id}.zip"' + return response diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions/submissions.py similarity index 53% rename from backend/project/endpoints/submissions.py rename to backend/project/endpoints/submissions/submissions.py index 528b0e94..42f76450 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -1,302 +1,177 @@ -"""Submission API endpoint""" - -from urllib.parse import urljoin -from datetime import datetime -from os import getenv, path, makedirs -from zoneinfo import ZoneInfo -from shutil import rmtree -from dotenv import load_dotenv -from flask import Blueprint, request -from flask_restful import Resource -from sqlalchemy import exc -from project.executor import executor -from project.db_in import db -from project.models.submission import Submission, SubmissionStatus -from project.models.project import Project -from project.models.user import User -from project.utils.files import all_files_uploaded -from project.utils.user import is_valid_user -from project.utils.project import is_valid_project -from project.utils.query_agent import query_selected_from_model, delete_by_id_from_model -from project.utils.authentication import authorize_submission_request, \ - authorize_submissions_request, authorize_grader, \ - authorize_student_submission, authorize_submission_author - -from project.utils.submissions.evaluator import run_evaluator - -load_dotenv() -API_HOST = getenv("API_HOST") -UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") -BASE_URL = urljoin(f"{API_HOST}/", "/submissions") - -TIMEZONE = getenv("TIMEZONE", "GMT") - -submissions_bp = Blueprint("submissions", __name__) - -class SubmissionsEndpoint(Resource): - """API endpoint for the submissions""" - - @authorize_submissions_request - def get(self) -> dict[str, any]: - """Get all the submissions from a user for a project - - Returns: - dict[str, any]: The list of submission URLs - """ - - data = { - "url": BASE_URL - } - try: - # Filter by uid - uid = request.args.get("uid") - if uid is not None and (not uid.isdigit() or not User.query.filter_by(uid=uid).first()): - data["message"] = f"Invalid user (uid={uid})" - return data, 400 - - # Filter by project_id - project_id = request.args.get("project_id") - if project_id is not None \ - and (not project_id.isdigit() or - not Project.query.filter_by(project_id=project_id).first()): - data["message"] = f"Invalid project (project_id={project_id})" - return data, 400 - except exc.SQLAlchemyError: - data["message"] = "An error occurred while fetching the submissions" - return data, 500 - - return query_selected_from_model( - Submission, - urljoin(f"{API_HOST}/", "/submissions"), - select_values=[ - "submission_id", "uid", - "project_id", "grading", - "submission_time", "submission_status"], - url_mapper={ - "submission_id": BASE_URL, - "project_id": urljoin(f"{API_HOST}/", "projects"), - "uid": urljoin(f"{API_HOST}/", "users")}, - filters=request.args - ) - - @authorize_student_submission - def post(self) -> dict[str, any]: - """Post a new submission to a project - - Returns: - dict[str, any]: The URL to the submission - """ - - data = { - "url": BASE_URL - } - try: - with db.session() as session: - submission = Submission() - - # User - uid = request.form.get("uid") - valid, message = is_valid_user(session, uid) - if not valid: - data["message"] = message - return data, 400 - submission.uid = uid - - # Project - project_id = request.form.get("project_id") - valid, message = is_valid_project(session, project_id) - if not valid: - data["message"] = message - return data, 400 - submission.project_id = int(project_id) - - # Submission time - submission.submission_time = datetime.now(ZoneInfo(TIMEZONE)) - - # Submission files - submission.submission_path = "" # Must be set on creation - files = request.files.getlist("files") - - # Check files otherwise stop - project = session.get(Project, submission.project_id) - if project.regex_expressions and \ - (not files or not all_files_uploaded(files, project.regex_expressions)): - data["message"] = "No files were uploaded" if not files else \ - "Not all required files were uploaded " \ - f"(required files={','.join(project.regex_expressions)})" - return data, 400 - - deadlines = project.deadlines - is_late = True - for deadline in deadlines: - if submission.submission_time < deadline.deadline: - is_late = False - - # Submission_id needed for the file location - session.add(submission) - session.commit() - - # Save the files - submission.submission_path = path.join(UPLOAD_FOLDER, str(submission.project_id), - "submissions", str(submission.submission_id)) - try: - makedirs(submission.submission_path, exist_ok=True) - input_folder = path.join(submission.submission_path, "submission") - makedirs(input_folder, exist_ok=True) - for file in files: - file.save(path.join(input_folder, file.filename)) - except OSError: - rmtree(submission.submission_path) - session.rollback() - - if project.runner: - submission.submission_status = SubmissionStatus.RUNNING - executor.submit( - run_evaluator, - submission, - path.join(UPLOAD_FOLDER, str(project.project_id)), - project.runner.value, - False) - else: - submission.submission_status = SubmissionStatus.LATE if is_late \ - else SubmissionStatus.SUCCESS - - data["message"] = "Successfully fetched the submissions" - data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") - data["data"] = { - "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), - "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), - "grading": submission.grading, - "submission_time": submission.submission_time, - "submission_status": submission.submission_status - } - return data, 202 - - except exc.SQLAlchemyError: - session.rollback() - data["message"] = "An error occurred while creating a new submission" - return data, 500 - -class SubmissionEndpoint(Resource): - """API endpoint for the submission""" - - @authorize_submission_request - def get(self, submission_id: int) -> dict[str, any]: - """Get the submission given an submission ID - - Args: - submission_id (int): Submission ID - - Returns: - dict[str, any]: The submission - """ - - data = { - "url": urljoin(f"{BASE_URL}/", str(submission_id)) - } - try: - with db.session() as session: - submission = session.get(Submission, submission_id) - if submission is None: - data["url"] = urljoin(f"{API_HOST}/", "/submissions") - data["message"] = f"Submission (submission_id={submission_id}) not found" - return data, 404 - - data["message"] = "Successfully fetched the submission" - data["data"] = { - "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), - "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), - "grading": submission.grading, - "submission_time": submission.submission_time, - "submission_status": submission.submission_status - } - return data, 200 - - except exc.SQLAlchemyError: - data["message"] = \ - f"An error occurred while fetching the submission (submission_id={submission_id})" - return data, 500 - - @authorize_grader - def patch(self, submission_id:int) -> dict[str, any]: - """Update some fields of a submission given a submission ID - - Args: - submission_id (int): Submission ID - - Returns: - dict[str, any]: A message - """ - - data = { - "url": urljoin(f"{BASE_URL}/", str(submission_id)) - } - try: - with db.session() as session: - # Get the submission - submission = session.get(Submission, submission_id) - if submission is None: - data["url"] = urljoin(f"{API_HOST}/", "/submissions") - data["message"] = f"Submission (submission_id={submission_id}) not found" - return data, 404 - - # Update the grading field - grading = request.form.get("grading") - if grading is not None: - try: - grading_float = float(grading) - if 0 <= grading_float <= 20: - submission.grading = grading_float - else: - data["message"] = "Invalid grading (grading=0-20)" - return data, 400 - except ValueError: - data["message"] = "Invalid grading (not a valid float)" - return data, 400 - - # Save the submission - session.commit() - - data["message"] = f"Submission (submission_id={submission_id}) patched" - data["url"] = urljoin(f"{BASE_URL}/", str(submission.submission_id)) - data["data"] = { - "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), - "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), - "grading": submission.grading, - "time": submission.submission_time, - "status": submission.submission_status - } - return data, 200 - - except exc.SQLAlchemyError: - session.rollback() - data["message"] = \ - f"An error occurred while patching submission (submission_id={submission_id})" - return data, 500 - - @authorize_submission_author - def delete(self, submission_id: int) -> dict[str, any]: - """Delete a submission given a submission ID - - Args: - submission_id (int): Submission ID - - Returns: - dict[str, any]: A message - """ - - return delete_by_id_from_model( - Submission, - "submission_id", - submission_id, - BASE_URL - ) - -submissions_bp.add_url_rule("/submissions", view_func=SubmissionsEndpoint.as_view("submissions")) -submissions_bp.add_url_rule( - "/submissions/<int:submission_id>", - view_func=SubmissionEndpoint.as_view("submission") -) +""" +This module contains the API endpoint for the submissions +""" + +from os import path, makedirs, getenv +from urllib.parse import urljoin +from datetime import datetime +from zoneinfo import ZoneInfo +from shutil import rmtree +from flask import request +from flask_restful import Resource +from sqlalchemy import exc +from project.executor import executor +from project.db_in import db +from project.models.submission import Submission, SubmissionStatus +from project.models.project import Project +from project.models.user import User +from project.utils.files import all_files_uploaded +from project.utils.user import is_valid_user +from project.utils.project import is_valid_project +from project.utils.query_agent import query_selected_from_model +from project.utils.authentication import authorize_student_submission, authorize_submissions_request +from project.utils.submissions.evaluator import run_evaluator + +API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/submissions") +TIMEZONE = getenv("TIMEZONE", "GMT") + +class SubmissionsEndpoint(Resource): + """API endpoint for the submissions""" + + @authorize_submissions_request + def get(self) -> dict[str, any]: + """Get all the submissions from a user for a project + + Returns: + dict[str, any]: The list of submission URLs + """ + + data = { + "url": BASE_URL + } + try: + # Filter by uid + uid = request.args.get("uid") + if uid is not None and (not uid.isdigit() or not User.query.filter_by(uid=uid).first()): + data["message"] = f"Invalid user (uid={uid})" + return data, 400 + + # Filter by project_id + project_id = request.args.get("project_id") + if project_id is not None \ + and (not project_id.isdigit() or + not Project.query.filter_by(project_id=project_id).first()): + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 + except exc.SQLAlchemyError: + data["message"] = "An error occurred while fetching the submissions" + return data, 500 + + return query_selected_from_model( + Submission, + urljoin(f"{API_HOST}/", "/submissions"), + select_values=[ + "submission_id", "uid", + "project_id", "grading", + "submission_time", "submission_status"], + url_mapper={ + "submission_id": BASE_URL, + "project_id": urljoin(f"{API_HOST}/", "projects"), + "uid": urljoin(f"{API_HOST}/", "users")}, + filters=request.args + ) + + @authorize_student_submission + def post(self) -> dict[str, any]: + """Post a new submission to a project + + Returns: + dict[str, any]: The URL to the submission + """ + + data = { + "url": BASE_URL + } + try: + with db.session() as session: + submission = Submission() + + # User + valid, message = is_valid_user(session, request.form.get("uid")) + if not valid: + data["message"] = message + return data, 400 + submission.uid = request.form.get("uid") + + # Project + project_id = request.form.get("project_id") + valid, message = is_valid_project(session, project_id) + if not valid: + data["message"] = message + return data, 400 + submission.project_id = int(project_id) + + # Submission time + submission.submission_time = datetime.now(ZoneInfo(TIMEZONE)) + + # Submission files + submission.submission_path = "" # Must be set on creation + files = request.files.getlist("files") + + # Check files otherwise stop + project = session.get(Project, submission.project_id) + if project.regex_expressions and \ + (not files or not all_files_uploaded(files, project.regex_expressions)): + data["message"] = "No files were uploaded" if not files else \ + "Not all required files were uploaded " \ + f"(required files={','.join(project.regex_expressions)})" + return data, 400 + + deadlines = project.deadlines + is_late = deadlines is not None + if deadlines: + for deadline in deadlines: + if submission.submission_time < deadline.deadline: + is_late = False + + if project.runner: + submission.submission_status = SubmissionStatus.RUNNING + else: + submission.submission_status = SubmissionStatus.LATE if is_late \ + else SubmissionStatus.SUCCESS + + # Submission_id needed for the file location + session.add(submission) + session.commit() + + # Save the files + submission.submission_path = path.join(UPLOAD_FOLDER, str(submission.project_id), + "submissions", str(submission.submission_id)) + try: + makedirs(submission.submission_path, exist_ok=True) + input_folder = path.join(submission.submission_path, "submission") + makedirs(input_folder, exist_ok=True) + for file in files: + file.save(path.join(input_folder, file.filename)) + except OSError: + rmtree(submission.submission_path) + session.rollback() + + if project.runner: + submission.submission_status = SubmissionStatus.RUNNING + executor.submit( + run_evaluator, + submission, + path.join(UPLOAD_FOLDER, str(project.project_id)), + project.runner.value, + False) + + data["message"] = "Successfully fetched the submissions" + data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") + data["data"] = { + "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), + "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), + "grading": submission.grading, + "submission_time": submission.submission_time, + "submission_status": submission.submission_status + } + return data, 202 + + except exc.SQLAlchemyError as e: + print(e) + session.rollback() + data["message"] = "An error occurred while creating a new submission" + return data, 500 From d861ec9b47fce8df32206b807aac8dde3544befd Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Wed, 17 Apr 2024 19:00:38 +0200 Subject: [PATCH 275/377] fix the no status on workflow badges (#211) * dev changes? * removed branch argument * removed random changes --- README.md | 8 ++++---- backend/README.md | 4 ++-- frontend/README.md | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0c446923..510b4a14 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # UGent-3 project peristerónas -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg) ## Introduction Project peristerónas was created to aid both teachers and students in achieving a clear overview of deadlines and projects that need to be submitted. diff --git a/backend/README.md b/backend/README.md index d978ac22..b7cd5ee4 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # Project pigeonhole backend -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg?branch=development) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg?branch=development) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-backend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-backend.yaml/badge.svg) ## Prerequisites **1. Clone the repo** ```sh diff --git a/frontend/README.md b/frontend/README.md index 6d217e0b..49c9c481 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,6 +1,6 @@ # Project pigeonhole frontend -![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg?branch=development) -![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg?branch=development) +![tests](https://github.com/SELab-2/UGent-3/actions/workflows/ci-test-frontend.yaml/badge.svg) +![linter](https://github.com/SELab-2/UGent-3/actions/workflows/ci-linter-frontend.yaml/badge.svg) ## Prerequisites **1. Clone the repo** ```sh From 0b6698aba5bb34cb7bbbfced40b618ee808354d1 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 18 Apr 2024 14:27:17 +0200 Subject: [PATCH 276/377] The submissions form for project page (#124) * fixed beginning of project submission form * changed * changes * changed * removed print * form works * project form is working * backend fixes * backend linter * linter * fixed language support * frontend linter * packagelock * linter * letsgoo * unused import :nerd: * augh * siebe review * aron pr review * merge conf * pr fixed * veel * :) * idk how it works but it does * changes, a lot of them * does this fix * conftest * dev changes? * tests are working * working regexes * veel hihi * removed unused React * working form * linter and tests * small changes * pr review jeej * tag and route * removed CORS * removed unused imports * hopefully aron is happy nowgit pushgit pushgit push! * comment changed --- .../endpoints/projects/endpoint_parser.py | 14 +- .../project/endpoints/projects/projects.py | 3 +- backend/project/utils/query_agent.py | 8 +- backend/test_auth_server/__main__.py | 4 +- backend/tests/endpoints/conftest.py | 7 +- backend/tests/endpoints/project_test.py | 2 +- frontend/.eslintrc.cjs | 1 + frontend/package-lock.json | 41 ++ frontend/package.json | 3 +- .../locales/en/projectformTranslation.json | 21 + frontend/public/locales/en/translation.json | 35 ++ .../locales/nl/projectformTranslation.json | 21 + frontend/public/locales/nl/translation.json | 25 + frontend/src/App.tsx | 8 +- .../components/Calender/DeadlineCalender.tsx | 1 - frontend/src/components/Header/Header.tsx | 4 +- .../components/ProjectForm/AdvancedRegex.tsx | 53 +++ .../ProjectForm/FileStructureForm.tsx | 99 ++++ .../components/ProjectForm/ProjectForm.tsx | 435 ++++++++++++++++++ .../components/ProjectForm/RunnerSelecter.tsx | 84 ++++ .../create_project/ProjectCreateHome.tsx | 29 ++ frontend/tsconfig.json | 5 +- 22 files changed, 881 insertions(+), 22 deletions(-) create mode 100644 frontend/public/locales/en/projectformTranslation.json create mode 100644 frontend/public/locales/nl/projectformTranslation.json create mode 100644 frontend/src/components/ProjectForm/AdvancedRegex.tsx create mode 100644 frontend/src/components/ProjectForm/FileStructureForm.tsx create mode 100644 frontend/src/components/ProjectForm/ProjectForm.tsx create mode 100644 frontend/src/components/ProjectForm/RunnerSelecter.tsx create mode 100644 frontend/src/pages/create_project/ProjectCreateHome.tsx diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 26e23b8a..48ef4874 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -15,7 +15,12 @@ help='Projects assignment file', location="form" ) -parser.add_argument('deadlines', type=str, help='Projects deadlines', location="form") +parser.add_argument( + 'deadlines', + type=str, + help='Projects deadlines', + location="form", action="append" +) parser.add_argument("course_id", type=str, help='Projects course_id', location="form") parser.add_argument( "visible_for_students", @@ -28,7 +33,8 @@ "regex_expressions", type=str, help='Projects regex expressions', - location="form" + location="form", + action="append" ) parser.add_argument("runner", type=str, help='Projects runner', location="form") @@ -39,13 +45,15 @@ def parse_project_params(): Return a dict of every non None value in the param """ args = parser.parse_args() + result_dict = {} for key, value in args.items(): if value is not None: if "deadlines" == key: - deadlines_parsed = json.loads(value) + deadlines_parsed = value new_deadlines = [] for deadline in deadlines_parsed: + deadline = json.loads(deadline) new_deadlines.append( ( deadline["description"], diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index dfc04895..abbaf3e4 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -27,7 +27,6 @@ class ProjectsEndpoint(Resource): Inherits from flask_restful.Resource class for implementing get method """ - @authorize_teacher def get(self, teacher_id=None): """ @@ -74,8 +73,8 @@ def post(self, teacher_id=None): if status_code == 400: return new_project, status_code - project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") + os.makedirs(project_upload_directory, exist_ok=True) if filename is not None: try: diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index e57cfeab..96e25045 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -3,7 +3,6 @@ delete, insert and query entries from the database. The functions are used by the routes to interact with the database. """ - from typing import Dict, List, Union from urllib.parse import urljoin from flask import jsonify @@ -11,8 +10,8 @@ from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.orm.query import Query from sqlalchemy.exc import SQLAlchemyError -from project.db_in import db from project.utils.misc import map_all_keys_to_url, models_to_dict, filter_model_fields +from project.db_in import db def delete_by_id_from_model( model: DeclarativeMeta, @@ -60,13 +59,13 @@ def create_model_instance(model: DeclarativeMeta, """ if required_fields is None: required_fields = [] + # Check if all non-nullable fields are present in the data missing_fields = [field for field in required_fields if field not in data or data[field] == ''] if missing_fields: return {"error": f"Missing required fields: {', '.join(missing_fields)}", "url": response_url_base}, 400 - filtered_data = filter_model_fields(model, data) new_instance: DeclarativeMeta = model(**filtered_data) db.session.add(new_instance) @@ -121,7 +120,8 @@ def query_selected_from_model(model: DeclarativeMeta, response_url: str, url_mapper: Dict[str, str] = None, select_values: List[str] = None, - filters: Dict[str, Union[str, int]]=None): + filters: Dict[str, Union[str, int]]=None + ): """ Query entries from the database giving the model corresponding to a certain table and the filters to apply to the query. diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index 1dc6302f..adaea5b8 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -1,7 +1,7 @@ """Main entry point for the application.""" + from dotenv import load_dotenv -from flask import Flask -from flask import Blueprint, request +from flask import Flask, Blueprint, request from flask_restful import Resource, Api diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 8cbba4e3..b6311936 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -4,7 +4,6 @@ from datetime import datetime from zoneinfo import ZoneInfo from typing import Any - import pytest from pytest import fixture, FixtureRequest from flask.testing import FlaskClient @@ -235,6 +234,7 @@ def course_ad(course_teacher_ad: User): @pytest.fixture def valid_project_entry(session, valid_project): """A project for testing, with the course as the course it belongs to""" + valid_project["deadlines"] = [valid_project["deadlines"]] project = Project(**valid_project) session.add(project) @@ -244,14 +244,15 @@ def valid_project_entry(session, valid_project): @pytest.fixture def valid_project(course): """A function that return the json form data of a project""" + data = { "title": "Project", "description": "Test project", - "deadlines": [{"deadline": "2024-02-25T12:00:00", "description": "Deadline 1"}], + "deadlines": {"deadline": "2024-02-25T12:00:00", "description": "Deadline 1"}, "course_id": course.course_id, "visible_for_students": True, "archived": False, - "regex_expressions": ["*.pdf", "*.txt"] + "regex_expressions": "*.pdf" } return data diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index db56fe54..a65aa38c 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -50,8 +50,8 @@ def test_getting_all_projects(client): def test_post_project(client, valid_project): """Test posting a project to the database and testing if it's present""" - valid_project["deadlines"] = json.dumps(valid_project["deadlines"]) + with open("tests/resources/testzip.zip", "rb") as zip_file: valid_project["assignment_file"] = zip_file # post the project diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 3cd9c9a6..8b34a81b 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -50,5 +50,6 @@ module.exports = { "jsdoc/require-yields-check": 1, "jsdoc/tag-lines": 1, "jsdoc/valid-types": 1, + "no-console": "error" }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c85efc2..7c101d25 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", + "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", "@mui/x-data-grid": "^7.1.1", @@ -1145,6 +1146,46 @@ } } }, + "node_modules/@mui/lab": { + "version": "5.0.0-alpha.170", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.170.tgz", + "integrity": "sha512-0bDVECGmrNjd3+bLdcLiwYZ0O4HP5j5WSQm5DV6iA/Z9kr8O6AnvZ1bv9ImQbbX7Gj3pX4o43EKwCutj3EQxQg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.40", + "@mui/system": "^5.15.15", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material": ">=5.15.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, "node_modules/@mui/material": { "version": "5.15.15", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 51fdab0d..89c7e34f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,11 +15,12 @@ "@emotion/react": "^11.11.3", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.10", + "@mui/lab": "^5.0.0-alpha.170", "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", "@mui/x-data-grid": "^7.1.1", - "axios": "^1.6.8", "@mui/x-date-pickers": "^7.1.1", + "axios": "^1.6.8", "dayjs": "^1.11.10", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", diff --git a/frontend/public/locales/en/projectformTranslation.json b/frontend/public/locales/en/projectformTranslation.json new file mode 100644 index 00000000..159e0d94 --- /dev/null +++ b/frontend/public/locales/en/projectformTranslation.json @@ -0,0 +1,21 @@ +{ + "filestructure": { + "title": "Fill in the file restrictions below", + "startsWith": "Filename starts with", + "endsWith": "Filename ends with", + "contains": "Filename contains string", + "helperRegexText": "Regex can't be empty or already added" + }, + "advancedRegex": { + "title": "Please fill in the desired Regex", + "helperRegexText": "Restriction can't be empty or already added", + "regexInfo": "If your're having trouble with regex please refer to this", + "cheatsheet": "cheatsheet" + }, + "runnerComponent": { + "testWarning": "No appropriate test file found, can't upload project", + "clearSelected": "Clear Selection", + "tooltipRunner": "If you're having trouble figuring out the runner please refer to the docs", + "userDocs": "runner user docs" + } +} \ No newline at end of file diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index d0a612e8..56405918 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -5,7 +5,14 @@ "header": { "myProjects": "My Projects", "myCourses": "My Courses", + "projectCreate": "Create Project", "login": "Login", + "home": "Home", + "tag": "en", + "homepage": "Homepage", + "projectUploadForm": "Project upload form" + }, + "home": { "home": "Home", "tag": "en", "homepage": "Homepage" @@ -37,5 +44,33 @@ "hoursAgo": "hours ago", "minutesAgo": "minutes ago", "justNow": "just now" + }, + "projectForm": { + "projectTitle": "Title", + "projectDescription": "Project description", + "projectCourse": "Course", + "projectDeadline": "Project deadline", + "visibleForStudents": "Visible for students", + "uploadFile": "Upload file", + "regex": "Add Regex", + "selectCourseText": "Select a course", + "testWarning": "Warning: This assignment doesn't contain tests", + "helperText": "Please fill in a valid deadline for the project", + "uploadProject": "Upload project", + "regexStructure": "Regex structure", + "uploadError": "Project isn't formatted appropriately", + "projectHeader": "Upload a project", + "deadline": "deadline", + "description": "Description", + "zipFile": "Zipfile", + "helperRegexText": "Regex can't be empty or already added", + "fileInfo": "The uploaded file must be a .zip file, if you want automatic tests you should include a Dockerfile or a run_tests.sh.\n For more info you should see", + "userDocs": "user guide", + "visibleForStudentsTooltip": "If this is checked the project will be visible to the students after upload", + "noDeadlinesPlaceholder": "No deadlines present yet", + "noFilesPlaceholder": "No assignment files given yet", + "noRegexPlaceholder": "No regex added yet", + "clearSelected": "Clear Selection", + "faultySubmission": "Some fields were left open or there is no valid runner/file combination" } } \ No newline at end of file diff --git a/frontend/public/locales/nl/projectformTranslation.json b/frontend/public/locales/nl/projectformTranslation.json new file mode 100644 index 00000000..9835abc8 --- /dev/null +++ b/frontend/public/locales/nl/projectformTranslation.json @@ -0,0 +1,21 @@ +{ + "filestructure": { + "title": "Vul de gewenste bestand restrictie in", + "startsWith": "Bestandnaam start met", + "endsWith": "Bestandnaam eindigt met", + "contains": "Bestandnaam bevat", + "helperRegexText": "De gegeven regex mag niet leeg of al toegevoegd zijn" + }, + "advancedRegex": { + "title": "Vul de gewenste regex in", + "helperRegexText": "De gegeven regex mag niet leeg of al toegevoegd zijn", + "regexInfo": "Indien moeilijkheden worden ondervonden met regex, bekijk deze", + "cheatsheet": "cheatsheet" + }, + "runnerComponent": { + "testWarning": "Geen compatibele testen gevonden met de runner", + "clearSelected": "Deselecteer keuze", + "tooltipRunner": "Als je moeilijkheden ondervindt om de runner te gebruiken, gebruik volgende documentatie", + "userDocs": "runner user docs" + } +} \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index f30d871e..6e9312d2 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -5,6 +5,13 @@ "header": { "myProjects": "Mijn Projecten", "myCourses": "Mijn Vakken", + "login": "Aanmelden", + "home": "Home", + "tag": "nl", + "homepage": "Homepage", + "projectUploadForm": "Project uploaden" + }, + "home": { "login": "Aanmelden", "home": "Home", "tag": "nl", @@ -15,6 +22,24 @@ "submit": "Opslaan", "emptyCourseNameError": "Vak naam mag niet leeg zijn" }, + "projectForm": { + "projectTitle": "Titel", + "projectDescription": "Beschrijving", + "projectCourse": "Vak", + "projectDeadline": "Project deadline", + "visibleForStudents": "Zichtbaar voor studenten", + "uploadFile": "Upload bestand", + "regex": "Voeg Regex toe", + "selectCourseText": "Selecteer een vak", + "testWarning": "Opgelet: Deze opgave bevat geen tests", + "helperText": "Selecteer een geldige deadline voor het project", + "uploadProject": "Upload project", + "regexStructure": "Regex structuur", + "uploadError": "Project is niet goed geformatteerd", + "noDeadlinesPlaceholder": "Nog geen opgegeven deadlines", + "noFilesPlaceholder": "Nog geen opgave bestanden geupload", + "noRegexPlaceholder": "Nog geen regex toegevoegd" + }, "projectView": { "submitNetworkError": "Er is iets mislopen bij het opslaan van uw indiening. Probeer het later opnieuw.", "selected": "Geselecteerd", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6ab48f5d..29035b91 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,9 @@ -import { Route,RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; +import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; import Layout from "./components/Header/Layout"; import Home from "./pages/home/Home"; import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; +import ProjectCreateHome from "./pages/create_project/ProjectCreateHome.tsx"; const router = createBrowserRouter( createRoutesFromElements( @@ -13,13 +14,16 @@ const router = createBrowserRouter( <Route path="project" > <Route path=":projectId" element={<ProjectView />}/> </Route> + <Route path="projects"> + <Route path="create" element={<ProjectCreateHome />} /> + </Route> </Route> </Route> ) ); /** - * This component is the main application component that will be rendered by the ReactDOM. + * This component is the main application component that will be rendered by the ReactDOM. * @returns - The main application component */ export default function App(): React.JSX.Element { diff --git a/frontend/src/components/Calender/DeadlineCalender.tsx b/frontend/src/components/Calender/DeadlineCalender.tsx index 86948bcd..5ac8ad6d 100644 --- a/frontend/src/components/Calender/DeadlineCalender.tsx +++ b/frontend/src/components/Calender/DeadlineCalender.tsx @@ -172,7 +172,6 @@ function DeadlineDescriptionMenu({ let newDeadline = day.clone(); newDeadline = newDeadline.hour(time.hour()); newDeadline = newDeadline.minute(time.minute()); - console.log(newDeadline.isSame(day, "day")); onNewDeadline({ deadline: newDeadline.toString(), description }); } setDescription(""); diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 6be68f92..d807c5b8 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -7,14 +7,14 @@ import { MenuItem, Toolbar, Typography, + List, Drawer, Grid, - List, ListItemButton, ListItemText } from "@mui/material"; import MenuIcon from "@mui/icons-material/Menu"; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; import { useEffect, useState } from "react"; import LanguageIcon from "@mui/icons-material/Language"; import { Link } from "react-router-dom"; diff --git a/frontend/src/components/ProjectForm/AdvancedRegex.tsx b/frontend/src/components/ProjectForm/AdvancedRegex.tsx new file mode 100644 index 00000000..5ebc685d --- /dev/null +++ b/frontend/src/components/ProjectForm/AdvancedRegex.tsx @@ -0,0 +1,53 @@ +import {Button, IconButton, Stack, TextField, Tooltip, Typography} from "@mui/material"; +import {useTranslation} from "react-i18next"; +import {useState} from "react"; +import {Link} from "react-router-dom"; +import {InfoOutlined} from "@mui/icons-material"; + +interface Props { + handleSubmit: (regex: string) => boolean; + regexError: boolean; +} + +/** + * @returns Component for adding advanced regexes + */ +export default function AdvancedRegex({ handleSubmit, regexError } : Props) { + + const [regex, setRegex] = useState(""); + + const {t} = useTranslation('projectformTranslation', {keyPrefix: 'advancedRegex'}); + + const handleAdvancedSubmit = () => { + const result = handleSubmit(regex); + if (result) { + setRegex(""); + } + } + + return ( + <Stack + spacing={2} + > + <Typography variant="h6">{t("title")}</Typography> + <Stack direction="row" style={{display: "flex", alignItems:"center", width: "100%"}}> + <TextField + required + id="outlined-title" + label="Regex" + placeholder="Regex" + error={regexError} + helperText={regexError ? t("helperRegexText") : ''} + value={regex} + onChange={event => setRegex(event.target.value)} + ></TextField> + <Tooltip title={<Typography>{t("regexInfo")} <Link to="https://cheatography.com/davechild/cheat-sheets/regular-expressions/">{t("cheatsheet")}</Link></Typography>}> + <IconButton> + <InfoOutlined/> + </IconButton> + </Tooltip> + </Stack> + <Button variant="contained" onClick={() => handleAdvancedSubmit()}>Add custom regex</Button> + </Stack> + ) +} \ No newline at end of file diff --git a/frontend/src/components/ProjectForm/FileStructureForm.tsx b/frontend/src/components/ProjectForm/FileStructureForm.tsx new file mode 100644 index 00000000..4f528631 --- /dev/null +++ b/frontend/src/components/ProjectForm/FileStructureForm.tsx @@ -0,0 +1,99 @@ +import {Autocomplete, Button, Stack, TextField, Typography} from "@mui/material"; +import {useState} from "react"; +import {useTranslation} from "react-i18next"; + +interface Props { + handleSubmit: (regex: string) => boolean; + regexError: boolean; +} + +/** + * @returns Component for adding file restrictions + */ +export default function FileStuctureForm({ handleSubmit, regexError } : Props) { + + const extensions = [".txt", ".pdf", ".zip", ".7z", ".csv", ".doc", ".py", ".java", ".c"]; + + const {t} = useTranslation('projectformTranslation', {keyPrefix: 'filestructure'}); + + const [startsWith, setStartsWith] = useState(""); + const [endsWith, setEndsWith] = useState(""); + const [contains, setContains] = useState(""); + const [extension, setExtension] = useState(""); + + const handleExtensionChange = (value: string | null) => { + if (value) { + setExtension(value); + } + } + + const handleRegexSubmit = () => { + let regex = ""; + if (startsWith) { + regex += "^" + startsWith + } + if (contains) { + regex += "*" + contains + "*"; + } else if (startsWith || endsWith) { + regex += "*" + } + if (endsWith != "" && extension != "") { + regex += endsWith + extension + "$"; + } else if (endsWith != "") { + regex += endsWith + "$"; + } + const result = handleSubmit(regex); + if (result) { + setStartsWith(""); + setExtension(""); + setEndsWith(""); + setContains(""); + } + } + + return ( + <Stack + spacing={2} + > + <Typography variant="h6">{t("title")}</Typography> + <TextField + sx={{minWidth: 650}} + id="startsWith" + label={t("startsWith")} + placeholder={t("startsWith")} + error={regexError} + value={startsWith} + onChange={e => setStartsWith(e.target.value)} + /> + <TextField + sx={{minWidth: 650}} + id="endsWith" + label={t("endsWith")} + placeholder={t("endsWith")} + error={regexError} + value={endsWith} + onChange={e => setEndsWith(e.target.value)} + /> + <TextField + sx={{minWidth: 650}} + id="contains" + label="contains" + placeholder="contains" + error={regexError} + value={contains} + onChange={e => setContains(e.target.value)} + /> + <Autocomplete + id="extension" + freeSolo + value={extension} + onChange={(_event, value) => handleExtensionChange(value)} + renderInput={(params) => <TextField {...params} label="file extension" error={regexError} helperText={regexError ? t("helperRegexText") : ''} />} + options={extensions.map((t) => t)} + /> + <Button variant="contained" onClick={() => handleRegexSubmit()}> + Add file restriction + </Button> + </Stack> + ) +} \ No newline at end of file diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx new file mode 100644 index 00000000..7559368b --- /dev/null +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -0,0 +1,435 @@ +import { + Button, + Checkbox, + FormControlLabel, + FormHelperText, + Grid, + InputLabel, + MenuItem, Select, SelectChangeEvent, + TextField, + FormControl, Box, Typography, + Stack, TableContainer, Table, + TableRow, + TableCell, + TableHead, + TableBody, Paper, + Tooltip, IconButton, Tabs, Tab, +} from "@mui/material"; +import React, {useEffect, useState} from "react"; +import JSZip from 'jszip'; +import {useTranslation} from "react-i18next"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DeadlineCalender from "../Calender/DeadlineCalender.tsx"; +import { Deadline } from "../../types/deadline"; +import {InfoOutlined} from "@mui/icons-material"; +import {Link} from "react-router-dom"; +import FolderDragDrop from "../FolderUpload/FolderUpload.tsx"; +import TabPanel from "@mui/lab/TabPanel"; +import {TabContext} from "@mui/lab"; +import FileStuctureForm from "./FileStructureForm.tsx"; +import AdvancedRegex from "./AdvancedRegex.tsx"; +import RunnerSelecter from "./RunnerSelecter.tsx"; + +interface Course { + course_id: string; + name: string; + teacher: string; + ufora_id: string; +} + +interface RegexData { + key: number; + regex: string; +} + +const apiUrl = import.meta.env.VITE_APP_API_URL +const user = "Gunnar" + +/** + * @returns Form for uploading project + */ +export default function ProjectForm() { + + const { t } = useTranslation('translation', { keyPrefix: 'projectForm' }); + + // all the stuff needed for submitting a project + const [title, setTitle] = useState(''); + const [titleError, setTitleError] = useState(false); + + const [description, setDescription] = useState(''); + const [descriptionError, setDescriptionError] = useState(false); + + const [deadlines, setDeadlines] = useState<Deadline[]>([]) + const [files, setFiles] = useState<string[]>([]); + + const [visibleForStudents, setVisibleForStudents] = useState(false); + + const [regexExpressions, setRegexExpressions] = useState<RegexData[]>([]); + const [regexError, setRegexError] = useState(false); + + const [assignmentFile, setAssignmentFile] = useState<File>(); + const [filename, setFilename] = useState(""); + + const [courses, setCourses] = useState<Course[]>([]); + const [courseId, setCourseId] = useState<string>(''); + const [courseName, setCourseName] = useState<string>(''); + + const [containsDockerfile, setContainsDockerfile] = useState(false); + const [containsRuntest, setContainsRuntest] = useState(false); + + const [advanced, setAdvanced] = useState('1'); + const [runner, setRunner] = useState<string>(''); + const [validRunner, setValidRunner] = useState(true); + const [validSubmission, setValidSubmission] = useState(true); + + useEffect(() => { + fetchCourses(); + }, [regexError]); + + const handleRunnerSwitch = (newRunner: string) => { + if (newRunner === t('clearSelected')) { + setRunner(''); + } else { + setRunner(newRunner); + } + } + + const handleTabSwitch = (_event: React.SyntheticEvent, newAdvanced: string) => { + setAdvanced(newAdvanced); + }; + + const handleFileUpload2 = async (file: File) => { + setFiles([]); + setContainsRuntest(false); + setContainsDockerfile(false); + const zip = await JSZip.loadAsync(file); + const newFiles = []; + + let constainsDocker = false; + let containsRuntest = false; + for (const [, zipEntry] of Object.entries(zip.files)) { + if (!zipEntry.dir) { + if (zipEntry.name.trim().toLowerCase() === 'dockerfile') { + constainsDocker = true; + } + if (zipEntry.name.trim().toLowerCase() == 'run_tests.sh') { + containsRuntest = true; + } + newFiles.push(zipEntry.name); + } + } + + setFiles(newFiles); + setContainsDockerfile(constainsDocker) + setContainsRuntest(containsRuntest); + const {name} = file; + setAssignmentFile(file) + setFilename(name); + + if (runner === "CUSTOM") { + setValidRunner(constainsDocker); + setValidSubmission(constainsDocker); + } else { + setValidRunner(containsRuntest); + setValidSubmission(containsRuntest); + } + } + + const fetchCourses = async () => { + const response = await fetch(`${apiUrl}/courses?teacher=${user}`, { + headers: { + "Authorization": user + }, + }) + const jsonData = await response.json(); + if (jsonData.data) { + setCourses(jsonData.data); + } + } + + const appendRegex = (r: string) => { + if (r == '' || regexExpressions.some(reg => reg.regex == r)) { + setRegexError(true); + return false; + } + setRegexError(false); + let index; + const lastRegex = regexExpressions[regexExpressions.length-1]; + if (regexExpressions.length == 0) { + index = 0; + } else { + index = lastRegex.key+1; + } + + const newRegexExpressions = [...regexExpressions, { key: index, regex: r}]; + setRegexExpressions(newRegexExpressions); + + return true; + }; + + const handleSubmit = async (event: React.MouseEvent<HTMLButtonElement, globalThis.MouseEvent>) => { + event.preventDefault(); + + description == '' ? setDescriptionError(true) : setDescriptionError(false); + title == '' ? setTitleError(true) : setTitleError(false); + + if (!assignmentFile || !validRunner) { + setValidSubmission(false); + return; + } + + const assignmentFileBlob = new Blob([assignmentFile], { type: assignmentFile.type }); + + const formData = new FormData(); + + // Append fields to the FormData object + formData.append('title', title); + formData.append('description', description); + formData.append('visible_for_students', visibleForStudents.toString()); + formData.append('archived', 'false'); + formData.append('assignment_file', assignmentFileBlob, filename); + formData.append('course_id', courseId.toString()); + regexExpressions.forEach((expression,) => { + formData.append(`regex_expressions`, expression.regex); + }); + deadlines.forEach((deadline: Deadline) => { + formData.append("deadlines", + JSON.stringify({ + "deadline": deadline.deadline, + "description": deadline.description + }) + ); + }); + if (runner !== '') { + formData.append("runner", runner); + } + + const response = await fetch(`${apiUrl}/projects`, { + method: "post", + headers: { + "Authorization": user + }, + body: formData + }) + + if (!response.ok) { + throw new Error(t("uploadError")); + } + } + + const handleCourseChange = (e: SelectChangeEvent<string>) => { + const selectedCourseName = e.target.value as string; + const selectedCourse = courses.find(course => course.name === selectedCourseName); + if (selectedCourse) { + setCourseName(selectedCourse.name); + const parts = selectedCourse.course_id.split('/'); + const courseId = parts[parts.length - 1]; + setCourseId(courseId); + } + }; + + const handleDeadlineChange = (deadlines: Deadline[]) => { + setDeadlines(deadlines); + } + + const removeRegex = (regexToDelete: RegexData) => () => { + setRegexExpressions((regexes) => regexes.filter((regex) => regex.key !== regexToDelete.key)); + }; + + return ( + <Box + paddingLeft='75p' + paddingBottom='150px' + > + <FormControl + > + <Grid + container + direction="column" + spacing={3} + display='flex' + alignItems='left' + > + <Grid item sx={{ mt: 8 }}> + <TextField + sx={{ minWidth: 650 }} + required + id="outlined-title" + label={t("projectTitle")} + placeholder={t("projectTitle")} + error={titleError} + onChange={event => setTitle(event.target.value)} + /> + </Grid> + <Grid item> + <TextField + sx={{ minWidth: 650 }} + required + id="outlined-title" + label={t("projectDescription")} + placeholder={t("projectDescription")} + multiline + rows={4} + error={descriptionError} + onChange={event => setDescription(event.target.value)} + /> + </Grid> + <Grid item> + <FormControl> + <InputLabel id="course-simple-select-label">{t("projectCourse")}</InputLabel> + <Select + labelId="course-simple-select-label" + id="course-simple-select" + value={courseName} + label={t("projectCourse")} + onChange={handleCourseChange} + > + {courses.map(course => ( + <MenuItem key={course.course_id} value={course.name}> + {course.name} + </MenuItem> + ))} + </Select> + <FormHelperText>{t("selectCourseText")}</FormHelperText> + </FormControl> + </Grid> + <Grid item> + <DeadlineCalender + deadlines={[]} + onChange={(deadlines: Deadline[]) => handleDeadlineChange(deadlines)} + editable={true} /> + <TableContainer component={Paper}> + <Table sx={{ minWidth: 650 }}> + <TableHead> + <TableRow> + <TableCell sx={{ fontWeight: 'bold' }}>{t("deadline")}</TableCell> + <TableCell sx={{ fontWeight: 'bold' }} align="right">{t("description")}</TableCell> + </TableRow> + </TableHead> + <TableBody> + {deadlines.length === 0 ? ( // Check if deadlines is empty + <TableRow> + <TableCell colSpan={2} align="center">{t("noDeadlinesPlaceholder")}</TableCell> + </TableRow> + ) : ( + deadlines.map((deadline, index) => ( + <TableRow key={index} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> + <TableCell component="th" scope="row">{deadline.deadline}</TableCell> + <TableCell align="right">{deadline.description}</TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </TableContainer> + </Grid> + <Grid item> + <Stack direction="row" style={{display: "flex", alignItems:"center"}}> + <FormControlLabel control={<Checkbox defaultChecked />} label={t("visibleForStudents")} onChange={e=>setVisibleForStudents((e.target as HTMLInputElement).checked)}/> + <Tooltip title={<Typography variant="h6">{t("visibleForStudentsTooltip")}</Typography>}> + <IconButton> + <InfoOutlined/> + </IconButton> + </Tooltip> + </Stack> + </Grid> + <Grid item> + </Grid> + <Grid item> + <Stack direction="row" style={{display: "flex", alignItems:"center", paddingBottom: "40px"}}> + <FolderDragDrop onFileDrop={file => handleFileUpload2(file)} regexRequirements={[]} /> + <Tooltip style={{ height: "40%" }} title={<Typography variant="h6">{t("fileInfo")}: <Link to="/">{t("userDocs")}</Link></Typography>}> + <IconButton> + <InfoOutlined/> + </IconButton> + </Tooltip> + </Stack> + <TableContainer component={Paper}> + <Table sx={{ minWidth: "350px" }}> + <TableHead> + <TableRow> + <TableCell sx={{ fontWeight: 'bold' }}>{t("zipFile")}: {filename}</TableCell> + </TableRow> + </TableHead> + <TableBody> + {files.length === 0 ? ( // Check if files is empty + <TableRow> + <TableCell colSpan={1} align="center">{t("noFilesPlaceholder")}</TableCell> {/* Placeholder row */} + </TableRow> + ) : ( + files.map((file, index) => ( + <TableRow key={index} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> + <TableCell component="th" scope="row">{file}</TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </TableContainer> + {filename !== "" && (!containsRuntest && !containsDockerfile) && ( + <Typography style={{color: 'orange', paddingTop: "20px" }}> + {t("testWarning")} ⚠️ + </Typography> + )} + </Grid> + <Grid item sx={{ minWidth: "722px" }}> + <TabContext value={advanced}> + <Tabs value={advanced} onChange={handleTabSwitch}> + <Tab label="File restrictions" value="1"/> + <Tab label="Advanced mode" value="0"/> + </Tabs> + <TabPanel value="1"><FileStuctureForm handleSubmit={appendRegex} regexError={regexError}/></TabPanel> + <TabPanel value="0"><AdvancedRegex handleSubmit={appendRegex} regexError={regexError} /></TabPanel> + </TabContext> + </Grid> + <Grid item> + <TableContainer component={Paper}> + <Table sx={{ minWidth: 650 }} aria-label="simple table"> + <TableHead> + <TableRow> + <TableCell sx={{ fontWeight: 'bold' }}>Regex</TableCell> + <TableCell></TableCell> + </TableRow> + </TableHead> + <TableBody> + {regexExpressions.length === 0 ? ( // Check if regexExpressions is empty + <TableRow> + <TableCell colSpan={2} align="center">{t("noRegexPlaceholder")}</TableCell> {/* Placeholder row */} + </TableRow> + ) : ( + regexExpressions.map((regexData: RegexData) => ( + <TableRow key={regexData.key}> + <TableCell>{regexData.regex}</TableCell> + <TableCell align="right"> + <IconButton onClick={removeRegex(regexData)}> + <DeleteIcon/> + </IconButton> + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </TableContainer> + </Grid> + <Grid item> + <RunnerSelecter handleSubmit={handleRunnerSwitch} runner={runner} containsDocker={containsDockerfile} containsRuntests={containsRuntest} isValid={validRunner} setIsValid={setValidRunner} /> + </Grid> + <Grid item> + <Button variant="contained" onClick={e => { + return handleSubmit(e); + } + }>{t("uploadProject")}</Button> + { + !validSubmission && ( + <Typography style={{color: 'red', paddingTop: "20px" }}> + {t("faultySubmission")} ⚠️ + </Typography> + ) + } + </Grid> + </Grid> + </FormControl> + </Box> + ) +} \ No newline at end of file diff --git a/frontend/src/components/ProjectForm/RunnerSelecter.tsx b/frontend/src/components/ProjectForm/RunnerSelecter.tsx new file mode 100644 index 00000000..d91dc2f1 --- /dev/null +++ b/frontend/src/components/ProjectForm/RunnerSelecter.tsx @@ -0,0 +1,84 @@ +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, + Typography, + Tooltip, + Stack, IconButton +} from "@mui/material"; +import { Dispatch, SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; +import {InfoOutlined} from "@mui/icons-material"; +import {Link} from "react-router-dom"; + +interface Props { + handleSubmit: (runner: string) => void; + runner: string; + containsDocker: boolean; + containsRuntests: boolean; + isValid: boolean; + setIsValid: Dispatch<SetStateAction<boolean>>; +} + +/** + * @returns Component for selecting an appropriate runner + */ +export default function RunnerSelecter({ handleSubmit, runner, containsDocker, containsRuntests, isValid, setIsValid }: Props) { + + const { t } = useTranslation('projectformTranslation', { keyPrefix: 'runnerComponent' }); + + const runnerMapping: { [key: string]: boolean } = { + "GENERAL": containsRuntests, + "PYTHON": containsRuntests, + "CUSTOM": containsDocker, + [t("clearSelected")]: true + } + + const handleRunnerChange = (event: SelectChangeEvent) => { + const runner: string = event.target.value as string; + handleSubmit(runner); + setIsValid(runnerMapping[runner]); + } + + return ( + <> + <Stack direction="row"> + <FormControl sx={{ minWidth: "110px" }}> + <InputLabel id="select-runner-label">Runner</InputLabel> + <Select + labelId="select-runner-label" + id="runner-select" + value={runner} + label="Runner" + onChange={handleRunnerChange} + sx={{maxWidth: '260px'}} + > + <MenuItem disabled value="" key={0}> + Select a runner + </MenuItem> + {Object.keys(runnerMapping).map((runnerOption, index) => ( + runnerOption !== t("clearSelected") && <MenuItem key={index + 1} value={runnerOption}> + <Typography>{runnerOption}</Typography> + </MenuItem> + ))} + <MenuItem value={t("clearSelected")}>{t("clearSelected")}</MenuItem> + </Select> + </FormControl> + <Tooltip title={<Typography variant="h6">{t("tooltipRunner")}: <Link to="/">{t("userDocs")}</Link></Typography>}> + <IconButton> + <InfoOutlined/> + </IconButton> + </Tooltip> + </Stack> + { + !isValid && ( + <Typography style={{ color: 'red', paddingTop: "20px" }}> + {t("testWarning")} ⚠️ + </Typography> + ) + } + </> + ) +} diff --git a/frontend/src/pages/create_project/ProjectCreateHome.tsx b/frontend/src/pages/create_project/ProjectCreateHome.tsx new file mode 100644 index 00000000..fb43731b --- /dev/null +++ b/frontend/src/pages/create_project/ProjectCreateHome.tsx @@ -0,0 +1,29 @@ +/** + * + */ +import { Title } from "../../components/Header/Title.tsx"; +import ProjectForm from "../../components/ProjectForm/ProjectForm.tsx"; +import {Box} from "@mui/material"; +import {useTranslation} from "react-i18next"; + +/** + * Renders the home page for creating a project. + * @returns ProjectCreateHome - Returns the JSX element representing the home page for creating a project. + */ +export default function ProjectCreateHome() { + + const { t } = useTranslation('translation', { keyPrefix: 'projectForm' }); + + return ( + <Box + sx={{ + display: 'flex', + justifyContent: 'center', + }} + > + <Title title={t("projectHeader")}/> + <ProjectForm/> + </Box> + ) + ; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 046f3fef..3bde694e 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -23,6 +23,9 @@ "noImplicitThis": true, "strictNullChecks": true }, - "include": ["src"], + "include": [ + "src", + "node_modules/cypress/types/jquery/misc.d.ts" + ], "references": [{ "path": "./tsconfig.node.json" }] } From fb2a8854844d9dd21fa92e05ec13edc27667d2dc Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 18 Apr 2024 14:48:59 +0200 Subject: [PATCH 277/377] re added decorator (#214) --- backend/project/endpoints/projects/projects.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index abbaf3e4..3ed9a414 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -42,6 +42,7 @@ def get(self, teacher_id=None): filters=request.args ) + @authorize_teacher def post(self, teacher_id=None): """ Post functionality for project From 9fd38a550ca03b0b8ac1162370c8baaef461e87b Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 18 Apr 2024 15:48:25 +0200 Subject: [PATCH 278/377] Backend/project submission authorization issue (#210) * Fixing authorization for project * Fixing submission authorization issues * cleanup * Remove print and fix comments * Fixing submissions * Fixing projects * Cleanup * Fix filter placement --- .../project/endpoints/projects/projects.py | 64 ++- .../endpoints/submissions/submissions.py | 377 ++++++++++-------- backend/project/utils/authentication.py | 12 +- backend/tests/endpoints/submissions_test.py | 31 +- 4 files changed, 263 insertions(+), 221 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 3ed9a414..ae05894f 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -1,46 +1,78 @@ """ Module that implements the /projects endpoint of the API """ + import os from urllib.parse import urljoin import zipfile -from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy import and_ +from sqlalchemy.exc import SQLAlchemyError from flask import request, jsonify from flask_restful import Resource from project.db_in import db - from project.models.project import Project, Runner -from project.utils.query_agent import query_selected_from_model, create_model_instance -from project.utils.authentication import authorize_teacher - +from project.models.course import Course +from project.models.course_relation import CourseStudent, CourseAdmin +from project.utils.query_agent import create_model_instance +from project.utils.authentication import login_required_return_uid, authorize_teacher from project.endpoints.projects.endpoint_parser import parse_project_params +from project.utils.models.project_utils import get_course_of_project API_URL = os.getenv('API_HOST') UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER") - class ProjectsEndpoint(Resource): """ Class for projects endpoints Inherits from flask_restful.Resource class for implementing get method """ - @authorize_teacher - def get(self, teacher_id=None): + + @login_required_return_uid + def get(self, uid=None): """ Get method for listing all available projects that are currently in the API """ - response_url = urljoin(API_URL, "projects") - return query_selected_from_model( - Project, - response_url, - select_values=["project_id", "title", "description", "deadlines"], - url_mapper={"project_id": response_url}, - filters=request.args - ) + + data = { + "url": urljoin(f"{API_URL}/", "projects") + } + try: + # Get all the courses a user is part of + courses = CourseStudent.query.filter_by(uid=uid).\ + with_entities(CourseStudent.course_id).all() + courses += CourseAdmin.query.filter_by(uid=uid).\ + with_entities(CourseAdmin.course_id).all() + courses += Course.query.filter_by(teacher=uid).with_entities(Course.course_id).all() + courses = [c[0] for c in courses] # Remove the tuple wrapping the course_id + + # Filter the projects based on the query parameters + filters = dict(request.args) + conditions = [] + for key, value in filters.items(): + conditions.append(getattr(Project, key) == value) + + # Get the projects + projects = Project.query + projects = projects.filter(and_(*conditions)) if conditions else projects + projects = projects.all() + projects = [p for p in projects if get_course_of_project(p.project_id) in courses] + + # Return the projects + data["message"] = "Successfully fetched the projects" + data["data"] = [{ + "project_id": urljoin(f"{API_URL}/", f"projects/{p.project_id}"), + "title": p.title, + "course_id": urljoin(f"{API_URL}/", f"courses/{p.course_id}") + } for p in projects] + return data + + except SQLAlchemyError: + data["message"] = "An error occurred while fetching the projects" + return data, 500 @authorize_teacher def post(self, teacher_id=None): diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index 42f76450..89a4d1bb 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -1,177 +1,200 @@ -""" -This module contains the API endpoint for the submissions -""" - -from os import path, makedirs, getenv -from urllib.parse import urljoin -from datetime import datetime -from zoneinfo import ZoneInfo -from shutil import rmtree -from flask import request -from flask_restful import Resource -from sqlalchemy import exc -from project.executor import executor -from project.db_in import db -from project.models.submission import Submission, SubmissionStatus -from project.models.project import Project -from project.models.user import User -from project.utils.files import all_files_uploaded -from project.utils.user import is_valid_user -from project.utils.project import is_valid_project -from project.utils.query_agent import query_selected_from_model -from project.utils.authentication import authorize_student_submission, authorize_submissions_request -from project.utils.submissions.evaluator import run_evaluator - -API_HOST = getenv("API_HOST") -UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") -BASE_URL = urljoin(f"{API_HOST}/", "/submissions") -TIMEZONE = getenv("TIMEZONE", "GMT") - -class SubmissionsEndpoint(Resource): - """API endpoint for the submissions""" - - @authorize_submissions_request - def get(self) -> dict[str, any]: - """Get all the submissions from a user for a project - - Returns: - dict[str, any]: The list of submission URLs - """ - - data = { - "url": BASE_URL - } - try: - # Filter by uid - uid = request.args.get("uid") - if uid is not None and (not uid.isdigit() or not User.query.filter_by(uid=uid).first()): - data["message"] = f"Invalid user (uid={uid})" - return data, 400 - - # Filter by project_id - project_id = request.args.get("project_id") - if project_id is not None \ - and (not project_id.isdigit() or - not Project.query.filter_by(project_id=project_id).first()): - data["message"] = f"Invalid project (project_id={project_id})" - return data, 400 - except exc.SQLAlchemyError: - data["message"] = "An error occurred while fetching the submissions" - return data, 500 - - return query_selected_from_model( - Submission, - urljoin(f"{API_HOST}/", "/submissions"), - select_values=[ - "submission_id", "uid", - "project_id", "grading", - "submission_time", "submission_status"], - url_mapper={ - "submission_id": BASE_URL, - "project_id": urljoin(f"{API_HOST}/", "projects"), - "uid": urljoin(f"{API_HOST}/", "users")}, - filters=request.args - ) - - @authorize_student_submission - def post(self) -> dict[str, any]: - """Post a new submission to a project - - Returns: - dict[str, any]: The URL to the submission - """ - - data = { - "url": BASE_URL - } - try: - with db.session() as session: - submission = Submission() - - # User - valid, message = is_valid_user(session, request.form.get("uid")) - if not valid: - data["message"] = message - return data, 400 - submission.uid = request.form.get("uid") - - # Project - project_id = request.form.get("project_id") - valid, message = is_valid_project(session, project_id) - if not valid: - data["message"] = message - return data, 400 - submission.project_id = int(project_id) - - # Submission time - submission.submission_time = datetime.now(ZoneInfo(TIMEZONE)) - - # Submission files - submission.submission_path = "" # Must be set on creation - files = request.files.getlist("files") - - # Check files otherwise stop - project = session.get(Project, submission.project_id) - if project.regex_expressions and \ - (not files or not all_files_uploaded(files, project.regex_expressions)): - data["message"] = "No files were uploaded" if not files else \ - "Not all required files were uploaded " \ - f"(required files={','.join(project.regex_expressions)})" - return data, 400 - - deadlines = project.deadlines - is_late = deadlines is not None - if deadlines: - for deadline in deadlines: - if submission.submission_time < deadline.deadline: - is_late = False - - if project.runner: - submission.submission_status = SubmissionStatus.RUNNING - else: - submission.submission_status = SubmissionStatus.LATE if is_late \ - else SubmissionStatus.SUCCESS - - # Submission_id needed for the file location - session.add(submission) - session.commit() - - # Save the files - submission.submission_path = path.join(UPLOAD_FOLDER, str(submission.project_id), - "submissions", str(submission.submission_id)) - try: - makedirs(submission.submission_path, exist_ok=True) - input_folder = path.join(submission.submission_path, "submission") - makedirs(input_folder, exist_ok=True) - for file in files: - file.save(path.join(input_folder, file.filename)) - except OSError: - rmtree(submission.submission_path) - session.rollback() - - if project.runner: - submission.submission_status = SubmissionStatus.RUNNING - executor.submit( - run_evaluator, - submission, - path.join(UPLOAD_FOLDER, str(project.project_id)), - project.runner.value, - False) - - data["message"] = "Successfully fetched the submissions" - data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") - data["data"] = { - "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), - "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), - "grading": submission.grading, - "submission_time": submission.submission_time, - "submission_status": submission.submission_status - } - return data, 202 - - except exc.SQLAlchemyError as e: - print(e) - session.rollback() - data["message"] = "An error occurred while creating a new submission" - return data, 500 +""" +This module contains the API endpoint for the submissions +""" + +from os import path, makedirs, getenv +from urllib.parse import urljoin +from datetime import datetime +from zoneinfo import ZoneInfo +from shutil import rmtree +from flask import request +from flask_restful import Resource +from sqlalchemy import exc, and_ +from project.executor import executor +from project.db_in import db +from project.models.submission import Submission, SubmissionStatus +from project.models.project import Project +from project.models.user import User +from project.models.course import Course +from project.models.course_relation import CourseAdmin +from project.utils.files import all_files_uploaded +from project.utils.user import is_valid_user +from project.utils.project import is_valid_project +from project.utils.authentication import authorize_student_submission, login_required_return_uid +from project.utils.submissions.evaluator import run_evaluator +from project.utils.models.project_utils import get_course_of_project + +API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/submissions") +TIMEZONE = getenv("TIMEZONE", "GMT") + +class SubmissionsEndpoint(Resource): + """API endpoint for the submissions""" + + @login_required_return_uid + def get(self, uid=None) -> dict[str, any]: + """Get all the submissions from a user + + Returns: + dict[str, any]: The list of submission URLs + """ + + data = { + "url": BASE_URL + } + filters = dict(request.args) + try: + # Check the uid query parameter + user_id = filters.get("uid") + if user_id and not User.query.filter_by(uid=user_id).all(): + data["message"] = f"Invalid user (uid={user_id})" + return data, 400 + + # Check the project_id query parameter + project_id = filters.get("project_id") + if project_id: + if not project_id.isdigit() or \ + not Project.query.filter_by(project_id=project_id).all(): + data["message"] = f"Invalid project (project_id={project_id})" + return data, 400 + filters["project_id"] = int(project_id) + + # Get the courses + courses = Course.query.filter_by(teacher=uid).\ + with_entities(Course.course_id).all() + courses += CourseAdmin.query.filter_by(uid=uid).\ + with_entities(CourseAdmin.course_id).all() + courses = [c[0] for c in courses] # Remove the tuple wrapping the course_id + + # Filter the courses based on the query parameters + conditions = [] + for key, value in filters.items(): + conditions.append(getattr(Submission, key) == value) + + # Get the submissions + submissions = Submission.query + submissions = submissions.filter(and_(*conditions)) if conditions else submissions + submissions = submissions.all() + submissions = [ + s for s in submissions if + s.uid == uid or get_course_of_project(s.project_id) in courses + ] + + # Return the submissions + data["message"] = "Successfully fetched the submissions" + data["data"] = [{ + "submission_id": urljoin(BASE_URL, str(s.submission_id)), + "uid": urljoin(f"{API_HOST}/", f"users/{s.uid}"), + "project_id": urljoin(f"{API_HOST}/", f"projects/{s.project_id}"), + "grading": s.grading, + "submission_time": s.submission_time, + "submission_status": s.submission_status + } for s in submissions] + return data + + except exc.SQLAlchemyError: + data["message"] = "An error occurred while fetching the submissions" + return data, 500 + + @authorize_student_submission + def post(self) -> dict[str, any]: + """Post a new submission to a project + + Returns: + dict[str, any]: The URL to the submission + """ + + data = { + "url": BASE_URL + } + try: + with db.session() as session: + submission = Submission() + + # User + valid, message = is_valid_user(session, request.form.get("uid")) + if not valid: + data["message"] = message + return data, 400 + submission.uid = request.form.get("uid") + + # Project + project_id = request.form.get("project_id") + valid, message = is_valid_project(session, project_id) + if not valid: + data["message"] = message + return data, 400 + submission.project_id = int(project_id) + + # Submission time + submission.submission_time = datetime.now(ZoneInfo(TIMEZONE)) + + # Submission files + submission.submission_path = "" # Must be set on creation + files = request.files.getlist("files") + + # Check files otherwise stop + project = session.get(Project, submission.project_id) + if project.regex_expressions and \ + (not files or not all_files_uploaded(files, project.regex_expressions)): + data["message"] = "No files were uploaded" if not files else \ + "Not all required files were uploaded " \ + f"(required files={','.join(project.regex_expressions)})" + return data, 400 + + deadlines = project.deadlines + is_late = deadlines is not None + if deadlines: + for deadline in deadlines: + if submission.submission_time < deadline.deadline: + is_late = False + + if project.runner: + submission.submission_status = SubmissionStatus.RUNNING + else: + submission.submission_status = SubmissionStatus.LATE if is_late \ + else SubmissionStatus.SUCCESS + + # Submission_id needed for the file location + session.add(submission) + session.commit() + + # Save the files + submission.submission_path = path.join(UPLOAD_FOLDER, str(submission.project_id), + "submissions", str(submission.submission_id)) + try: + makedirs(submission.submission_path, exist_ok=True) + input_folder = path.join(submission.submission_path, "submission") + makedirs(input_folder, exist_ok=True) + for file in files: + file.save(path.join(input_folder, file.filename)) + except OSError: + rmtree(submission.submission_path) + session.rollback() + + if project.runner: + submission.submission_status = SubmissionStatus.RUNNING + executor.submit( + run_evaluator, + submission, + path.join(UPLOAD_FOLDER, str(project.project_id)), + project.runner.value, + False) + + data["message"] = "Successfully fetched the submissions" + data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") + data["data"] = { + "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), + "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), + "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), + "grading": submission.grading, + "submission_time": submission.submission_time, + "submission_status": submission.submission_status + } + return data, 202 + + except exc.SQLAlchemyError: + session.rollback() + data["message"] = "An error occurred while creating a new submission" + return data, 500 diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index c1a96248..ad9ba85f 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -99,6 +99,17 @@ def wrap(*args, **kwargs): return f(*args, **kwargs) return wrap +def login_required_return_uid(f): + """ + This function will check if the person sending a request to the API is logged in + and additionally create their user entry in the database if necessary + """ + @wraps(f) + def wrap(*args, **kwargs): + uid = return_authenticated_user_id() + kwargs["uid"] = uid + return f(*args, **kwargs) + return wrap def authorize_admin(f): """ @@ -248,7 +259,6 @@ def wrap(*args, **kwargs): ({"message": "You're not authorized to perform this action"}, 403))) return wrap - def authorize_submissions_request(f): """This function will check if the person sending a request to the API is logged in, and either the teacher/admin of the course or the student diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 41c73db7..5c429b4d 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -14,19 +14,19 @@ class TestSubmissionsEndpoint: ### GET SUBMISSIONS ### def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" - response = client.get("/submissions?uid=-20", headers={"Authorization":"teacher1"}) + response = client.get("/submissions?uid=-20", headers={"Authorization":"teacher"}) assert response.status_code == 400 def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" response = client.get("/submissions?project_id=123456789", - headers={"Authorization":"teacher1"}) - assert response.status_code == 404 # can't find course of project in authorization + headers={"Authorization":"teacher"}) + assert response.status_code == 400 assert "message" in response.json def test_get_submissions_wrong_project_type(self, client: FlaskClient): """Test getting submissions for a non-existing project of the wrong type""" - response = client.get("/submissions?project_id=zero", headers={"Authorization":"teacher1"}) + response = client.get("/submissions?project_id=zero", headers={"Authorization":"teacher"}) assert response.status_code == 400 assert "message" in response.json @@ -123,26 +123,3 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se "time": 'Thu, 14 Mar 2024 23:59:59 GMT', "status": 'FAIL' } - - ### DELETE SUBMISSION ### - def test_delete_submission_wrong_id(self, client: FlaskClient, session: Session): - """Test deleting a submission for a non-existing submission id""" - response = client.delete("submissions/0", headers={"Authorization":"student01"}) - data = response.json - assert response.status_code == 404 - assert data["message"] == "Submission with id: 0 not found" - - def test_delete_submission_correct(self, client: FlaskClient, session: Session): - """Test deleting a submission""" - project = session.query(Project).filter_by(title="B+ Trees").first() - submission = session.query(Submission).filter_by( - uid="student01", project_id=project.project_id - ).first() - response = client.delete(f"submissions/{submission.submission_id}", - headers={"Authorization":"student01"}) - data = response.json - assert response.status_code == 200 - assert data["message"] == "Resource deleted successfully" - assert submission.submission_id not in list(map( - lambda s: s.submission_id, session.query(Submission).all() - )) From d9cd5f8ff9c0cde565518235fc03102257edeb6f Mon Sep 17 00:00:00 2001 From: Matisse Sulzer <160239816+Matisse-Sulzer@users.noreply.github.com> Date: Thu, 18 Apr 2024 16:05:37 +0200 Subject: [PATCH 279/377] error pages (#93) * initial draft for error pages * indentation fixed * docs * error pages v1 * indentation fixed * layout adjusted, using RouterProvider instead of BrowserRouter to render errors correctly, tests added * linter * documentation * linter error parameters * Added i18n, changed general structure of App component and got rid of fade animation for the image * linting, package-lock.json fix * package file * changes to paramters in ErrorPage.tsx * use of keyPrefix * keyPrefix * fix --- frontend/.eslintrc.cjs | 1 + frontend/cypress/e2e/ErrorPage.cy.tsx | 16 ++++++ frontend/package-lock.json | 47 ++++++++++++++++-- frontend/package.json | 6 ++- frontend/public/img/error_pigeon.png | Bin 0 -> 2358 bytes frontend/public/{ => img}/logo_ugent.png | Bin frontend/public/locales/en/translation.json | 13 +++-- frontend/public/locales/nl/translation.json | 10 ++++ frontend/src/App.tsx | 10 ++-- frontend/src/Layout.tsx | 15 ++++++ frontend/src/pages/error/ErrorBoundary.tsx | 32 ++++++++++++ frontend/src/pages/error/ErrorPage.tsx | 51 ++++++++++++++++++++ 12 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 frontend/cypress/e2e/ErrorPage.cy.tsx create mode 100644 frontend/public/img/error_pigeon.png rename frontend/public/{ => img}/logo_ugent.png (100%) create mode 100644 frontend/src/Layout.tsx create mode 100644 frontend/src/pages/error/ErrorBoundary.tsx create mode 100644 frontend/src/pages/error/ErrorPage.tsx diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 8b34a81b..dd2f81ab 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -40,6 +40,7 @@ module.exports = { "jsdoc/require-param": 0, "jsdoc/require-param-description": 1, "jsdoc/require-param-name": 1, + "jsdoc/require-param-type": 0, "jsdoc/require-property": 1, "jsdoc/require-property-description": 1, "jsdoc/require-property-name": 1, diff --git a/frontend/cypress/e2e/ErrorPage.cy.tsx b/frontend/cypress/e2e/ErrorPage.cy.tsx new file mode 100644 index 00000000..7d998994 --- /dev/null +++ b/frontend/cypress/e2e/ErrorPage.cy.tsx @@ -0,0 +1,16 @@ +describe('Error page test', () => { + it('Error page should load appropriately', () => { + expect( + () => { + cy.request({ + method: 'POST', + path: '**', + body: {name: "fail"}, + failOnStatusCode: false + }).then(response => { + expect(response.status).to.be(404) // is supposed to be 404 + }) + } + ) + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7c101d25..4d9a738d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -30,12 +30,15 @@ "styled-components": "^6.1.8" }, "devDependencies": { + "@types/history": "^4.7.11", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", + "@types/react-router-dom": "^5.3.3", + "@types/scheduler": "^0.23.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.2.1", - "cypress": "^13.6.4", + "cypress": "^13.7.0", "eslint": "^8.56.0", "eslint-plugin-jsdoc": "^48.1.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -45,6 +48,7 @@ "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", + "scheduler": "^0.23.0", "typescript": "^5.2.2", "vite": "^5.1.7" } @@ -1806,6 +1810,12 @@ "@types/unist": "*" } }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1826,9 +1836,9 @@ "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" }, "node_modules/@types/node": { - "version": "20.12.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.6.tgz", - "integrity": "sha512-3KurE8taB8GCvZBPngVbp0lk5CKi8M9f9k1rsADh0Evdz5SzJ+Q+Hx9uHoFGsLnLnd1xmkDQr2hVhlA0Mn0lKQ==", + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dev": true, "optional": true, "dependencies": { @@ -1863,6 +1873,27 @@ "@types/react": "*" } }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -1871,6 +1902,12 @@ "@types/react": "*" } }, + "node_modules/@types/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-YIoDCTH3Af6XM5VuwGG/QL/CJqga1Zm3NkU3HZ4ZHK2fRMPYP1VczsTUqtsf43PH/iJNVlPHAo2oWX7BSdB2Hw==", + "dev": true + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -6368,7 +6405,7 @@ "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" diff --git a/frontend/package.json b/frontend/package.json index 89c7e34f..7ea862ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,10 +36,13 @@ "devDependencies": { "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", + "@types/react-router-dom": "^5.3.3", + "@types/history": "^4.7.11", + "@types/scheduler": "^0.23.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.2.1", - "cypress": "^13.6.4", + "cypress": "^13.7.0", "eslint": "^8.56.0", "eslint-plugin-jsdoc": "^48.1.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -49,6 +52,7 @@ "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.0", "react-i18next": "^14.1.0", + "scheduler": "^0.23.0", "typescript": "^5.2.2", "vite": "^5.1.7" } diff --git a/frontend/public/img/error_pigeon.png b/frontend/public/img/error_pigeon.png new file mode 100644 index 0000000000000000000000000000000000000000..f2264ead5c834fc6b0e317a2497ae8d11a88d241 GIT binary patch literal 2358 zcmbVO`BxGM7X?AF1@~|%r5yKNa$zLF6c@}S0ds9M%G6ZEB?r?KY;nN~P0LcVlrkkr z)U;g5r7S7OEv&Ts)ZB1yNXI2V=3kh1-hKDHcis>8ymQ_;H}iz2D|ENUZU6uP#kpY# z67K#NvNDod<;%S<A#j`{jwCCIWZAPBl71J}%`XlB*rWU}fPi~NY5;)jFb;c^L@Qbt zjmp;aR_Cu^My30?qyF`|X$E<86)W*0SdLO@r<IN4pxSjfJqvbx5Nl;gW^KG$KI%Rm zU{Sw*jgZmz<e=>NWM~@HKR=~9zciPWwNLsBIjSdNdwHbLY~j+~q$Pp<;kcc)NnCx? z0IGMSI5c$e_s|bOT&~W4&D}A9bKt@Lz_F`*m1;l%cnb6e6sKVcnvd>0%ul6{UZ<U3 zHx%BI&i!yCSe<HCU8fkTbd^Wt3(>MiWVWPfDl{-nZ5_0{1Pn@I`yTA5RiSB8fz?F@ z**tLKJXlL=F}f2r_6eLA)>#Y=l#7(o(x+s;Q`@9BG1frks;i!EP$okj*ancx`8Bzu zUoR!1*Z#N^VO2Q=^Z{VBDc*#VvBcM~(lN-k2U{^-ZrQK2%RrnN7(Wkb1qK%MpvwK5 zuDtMPqr-_^(ewtz<L{LesxCzXZEpPnn;ttN)m&(08e^2>&5y>VI^MRf2Q77)UC#Nj zd#s#|0dkbuAZClAVo%W{K8L99rMrhw$O!K*iTBP%W%L0;8q|i}>W;LcW6bh-GVA)Y z_?pT$=&_ExAD6el%eY{3*HwGnaF=~l4XXJp{%K<us8gX3Q2=`Seb@_%FZZb2&DC~k ze~i96r?CU#>PAHf&y%k>aJFpp1)RYt<+Yv)`~$DdAmy_B^x5xxeB<N=5i73p%6Gn8 ze~DEYEGeV)R%x+Ja&aWo6b^Sp=f_H0#%qh;BHpc>3ifIUBEhFXwt$_5Lw6crELYv@ zKwHTPEwmF9OQ5S3o_mEmuJFX7J4CsB8{ml3#;taw4Mk7Oa#2{wh`O}6sX%Q9Y;vy} zlIf@BlwXCxQ?=2mKgrU|J;1fiX{|Lsl=<VGHnZ2%iGx(l(Zb8U018BIy1KWiah~(> zHX!x4ZS$`)rk|}L7`QL~Z_ecNXZr)@Lv>6JofErl_t<PO?XeKEPLit-uwxXA=?IH8 zD<0q>=akk?M5cNR^>sux*HRTN^WXnoq3osW(HC;R8gZk3-lx`3IDP_#&poZw(x}p{ zwj4FKHJJGXQ6KW@iJ5fG{jG~3)*3F49X=|^PrJ3KWlH%0Bi)Lx7*+g~t^usWsN^{; z3HRKbbsd}jV2}gi2h~RU4oSV#pt;WJko-^?yuef5JcmCV1)0WT<AH^Jq-PGV8DBEg zhaRO2)jM$Y{kiO;gEbCj2k)!0Wb$7+R3dz1(tPQwPbDH?Q_8iN1n+#~?$Byv-c1ny zt)DH|krC)(C2~(hD$-clz5F}dFwt4^yKccg^N>1u+HWfv(E%ZCY01(|r!Jb-%GO`T zs3_8uR3e-jE)f*5hrT&ejZXH9=6%pRX>!l4IK!nK7OZP|=3QJ~4DfZ#c>lBm7TROj znMK4PHy|O+T^nGu9p`@m$C%}Wo&=^?gM2b~WiT^LXF&%h9vRqjxl7^c(cwfv-8unw zt25aT6OmHp;lqORlZg5ca~eu$Y_fjd^zK-7UP5}_=M`-9ru@SLJ_<=aoCVy)CR4PB z$-Tzvg80~#z02BT6^J_WC3R+5CFPJz4!R&_v!jzes0wUix*I{1v7D2!KDScR&X%<e zcjus3xKcN}C=4|4t>Sdwx7_fKtSX%Zm}Rhuc>n3YwQ8AV6~r75W#ui+s(I&{r_LXo zEN}FbdRtQ-ZifnSH?~_mK}+Xr?em)4xOlPpq+KcT#L!{q%^M^3e52sFiErtQHHC=V z4Yl-FuoaX3{e=0ylgStQ22(Y&e@!G|<HAsT=n!O$I|=nnH_J%8QpW_HTJ0SL^J>E8 zK(;bBDbD>>H>>8m`m!Hx5N>4dpDhc>sUzl(7tBr!ZzO~>Ag9^PrGbDPQU&tdMI@Ke z44{$SAZPbt7Ih@UD&#rD0ixOBfpMUE$Ib9kab@AAJ1gRu*Nf-7<Ubgo8^v(~o070m z_u@EnFN35Wk!FjED{&}`ZE+?ZUUG(H`xy#<Tk7siD7c`DM3xnF6AtvymF>2gjhqP? z`-TJl5F#&<D8{P5q}i{H-dDBXjvfh4+9fA2&_g?hz8?>xlss}v$Qyg{*?F<mH|;Va zi@CtBsH@MNhPfWLyP%g+4Sq9d82h{t3^}U;ugW^BTZ{q*K#v(F#Ryns&U~)Y0hVP^ z!6_Zp+^Y2CgdEaC<l`SjLz6av7;2t}Vr}2NRI5|+?U{3i%ZSoyv`^sqm!IwlpX&Jl zgWk_RXV<JdYDvfH@lt|sw;d$p2O`24*}yjK#iNhxK0%A@W)9B^$uUpvQy`BaFVjr) zU;B%HVDFO79q!y}Ms2jD33UCdOyq|8TChtg-}cOmV68skF2p<iV5U%bCkG1t@vnT? zl=(~Z!J3pGR^Zo&?9FE9BC&f1zJ1(sdDh^&pNAv`*&;Z|2Mxv<E#E6^F-o;5T1DIM zpGw!TSEPR4=G^$HG$Gpn-@a_p;CqH|E7`}VGkU_k4zQ{m&@S2HJ99G{W84JHyyRbu z$Sn{Q*xOY^$6xsTfXEO?r$CAdLtO~F1PXt;h?=5?DTh?(_%kt2Qsfkv8<Fp7Vw#M* zjmTO<2oq(~@82!Q7WhtnJ5C%aWaf-%v~Ao~l5-(lULm3`^ke0|2oNlNZo+HqIF?Ak zclEu?XP4mGEmkx1GxSD02TBsGH({c^r47lkEY&=vFLKFR>l)Rmf2l^)livd=UE?RL z?L7rwX%K4aee)3$)=eqZ>3`Y@N97dpoAw^@<elafv4<iG#Z`<5OMPZDFeOT<OR8#? zUz`z&el8&vl30PLmU8FTH_ojky-G|A`{M$u)j!GC6920;ka&v9`5nMxE-5xfRZ(Jm N061q)EY~qO?f>Z<Mh*Y~ literal 0 HcmV?d00001 diff --git a/frontend/public/logo_ugent.png b/frontend/public/img/logo_ugent.png similarity index 100% rename from frontend/public/logo_ugent.png rename to frontend/public/img/logo_ugent.png diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 56405918..b9cbb5e1 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -1,7 +1,4 @@ { - "home": { - "title": "Homepage" - }, "header": { "myProjects": "My Projects", "myCourses": "My Courses", @@ -45,6 +42,16 @@ "minutesAgo": "minutes ago", "justNow": "just now" }, + "error": { + "pageNotFound": "Page Not Found", + "pageNotFoundMessage": "The requested page was not found.", + "forbidden": "Forbidden", + "forbiddenMessage": "You don't have access to this resource.", + "clientError": "Client Error", + "clientErrorMessage": "A client error has occured.", + "serverError": "Server Error", + "serverErrorMessage": "A server error has occured." + }, "projectForm": { "projectTitle": "Title", "projectDescription": "Project description", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 6e9312d2..92be172f 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -62,5 +62,15 @@ "hoursAgo": "uur geleden", "minutesAgo": "minuten geleden", "justNow": "Zonet" + }, + "error": { + "pageNotFound": "Pagina Niet Gevonden", + "pageNotFoundMessage": "De opgevraagde pagina werd niet gevonden.", + "forbidden": "Verboden", + "forbiddenMessage": "Je hebt geen toegang tot deze bron.", + "clientError": "Client Fout", + "clientErrorMessage": "Er is een client fout opgetreden.", + "serverError": "Server Fout", + "serverErrorMessage": "Er is een server fout opgetreden." } } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 29035b91..881a3dff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,11 +3,12 @@ import Layout from "./components/Header/Layout"; import Home from "./pages/home/Home"; import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; +import { ErrorBoundary } from "./pages/error/ErrorBoundary.tsx"; import ProjectCreateHome from "./pages/create_project/ProjectCreateHome.tsx"; const router = createBrowserRouter( createRoutesFromElements( - <Route path="/" element={<Layout />}> + <Route path="/" element={<Layout />} errorElement={<ErrorBoundary />}> <Route index element={<Home />} /> <Route path=":lang" element={<LanguagePath/>}> <Route path="home" element={<Home />} /> @@ -27,8 +28,5 @@ const router = createBrowserRouter( * @returns - The main application component */ export default function App(): React.JSX.Element { - return ( - <RouterProvider router={router}> - </RouterProvider> - ); -} \ No newline at end of file + return <RouterProvider router={router} />; +} diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx new file mode 100644 index 00000000..2184d5c7 --- /dev/null +++ b/frontend/src/Layout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from "react-router-dom"; +import { Header } from "./components/Header/Header.tsx"; + +/** + * Basic layout component that will be used on all routes. + * @returns The Layout component + */ +export function Layout(): JSX.Element { + return ( + <> + <Header /> + <Outlet /> + </> + ); +} \ No newline at end of file diff --git a/frontend/src/pages/error/ErrorBoundary.tsx b/frontend/src/pages/error/ErrorBoundary.tsx new file mode 100644 index 00000000..d6d48ca9 --- /dev/null +++ b/frontend/src/pages/error/ErrorBoundary.tsx @@ -0,0 +1,32 @@ +import { useRouteError, isRouteErrorResponse } from "react-router-dom"; +import { ErrorPage } from "./ErrorPage.tsx"; +import { useTranslation } from "react-i18next"; + +/** + * This component will render the ErrorPage component with the appropriate data when an error occurs. + * @returns The ErrorBoundary component + */ +export function ErrorBoundary() { + const error = useRouteError(); + const { t } = useTranslation('translation', { keyPrefix: 'error' }); + + if (isRouteErrorResponse(error)) { + if (error.status == 404) { + return ( + <ErrorPage statusCode={"404"} statusTitle={t("pageNotFound")} message={t("pageNotFoundMessage")} /> + ); + } else if (error.status == 403) { + return ( + <ErrorPage statusCode={"403"} statusTitle={t("forbidden")} message={t("forbiddenMessage")} /> + ); + } else if (error.status >= 400 && error.status <= 499) { + return ( + <ErrorPage statusCode={error.statusText} statusTitle={t("clientError")} message={t("clientErrorMessage")} /> + ); + } else if (error.status >= 500 && error.status <= 599) { + return ( + <ErrorPage statusCode={error.statusText} statusTitle={t("serverError")} message={t("serverErrorMessage")} /> + ); + } + } +} diff --git a/frontend/src/pages/error/ErrorPage.tsx b/frontend/src/pages/error/ErrorPage.tsx new file mode 100644 index 00000000..edabd86e --- /dev/null +++ b/frontend/src/pages/error/ErrorPage.tsx @@ -0,0 +1,51 @@ +import { Grid, Typography } from "@mui/material"; + +/** + * This component will be rendered when an error occurs. + * @param statusCode - The status code of the error + * @param statusTitle - The name of the error + * @param message - Additional information about the error + * @returns The ErrorPage component + */ +export function ErrorPage( + { statusCode, statusTitle, message }: { statusCode: string, statusTitle: string, message: string } +): React.JSX.Element { + return ( + <Grid + container + justifyContent="center" + alignItems="center" + direction="column" + sx={{ minHeight: "100vh" }} + spacing={2} + > + <Grid item> + <Grid + container + justifyContent="center" + alignItems="center" + spacing={4} + > + <Grid item> + <Typography variant="h1"> + { statusCode } + </Typography> + </Grid> + <Grid item> + <img src="/img/error_pigeon.png" height="150vh" alt="icon" /> + </Grid> + </Grid> + </Grid> + <Grid item> + <Typography variant="h3"> + { statusTitle } + </Typography> + </Grid> + <Grid item> + <Typography variant="body1"> + { message } + </Typography> + </Grid> + </Grid> + ); +} From 12120eb62bc6a25cb88a2fdf551e8d81777cf5b0 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 18 Apr 2024 18:40:51 +0200 Subject: [PATCH 280/377] Added endpoint to add a user using a join code (#218) * Fix #71 * added course join endpoint * internal server error is 500 * fixed code docs --- .../endpoints/courses/courses_config.py | 3 + backend/project/endpoints/courses/join.py | 78 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 backend/project/endpoints/courses/join.py diff --git a/backend/project/endpoints/courses/courses_config.py b/backend/project/endpoints/courses/courses_config.py index f791031f..03f1abca 100644 --- a/backend/project/endpoints/courses/courses_config.py +++ b/backend/project/endpoints/courses/courses_config.py @@ -15,6 +15,7 @@ from project.endpoints.courses.course_details import CourseByCourseId from project.endpoints.courses.course_admin_relation import CourseForAdmins from project.endpoints.courses.course_student_relation import CourseToAddStudents +from project.endpoints.courses.join import CourseJoin courses_bp = Blueprint("courses", __name__) courses_api = Api(courses_bp) @@ -30,3 +31,5 @@ courses_bp.add_url_rule("/courses/<int:course_id>/students", view_func=CourseToAddStudents.as_view('course_students')) + +courses_bp.add_url_rule("/courses/join", view_func=CourseJoin.as_view('course_join')) diff --git a/backend/project/endpoints/courses/join.py b/backend/project/endpoints/courses/join.py new file mode 100644 index 00000000..16ed002f --- /dev/null +++ b/backend/project/endpoints/courses/join.py @@ -0,0 +1,78 @@ +""" +This file contains the endpoint to join a course using a join code +""" + + +from os import getenv +from datetime import datetime +from zoneinfo import ZoneInfo + +from flask import request +from flask_restful import Resource + +from sqlalchemy.exc import SQLAlchemyError + +from project.models.course_share_code import CourseShareCode +from project.models.course_relation import CourseStudent, CourseAdmin + + +TIMEZONE = getenv("TIMEZONE", "GMT") +API_URL = getenv("API_HOST") + +class CourseJoin(Resource): + """ + Class that will respond to the /courses/join link + students or admins with a join code can join a course + """ + + def post(self, uid=None): # pylint: disable=too-many-return-statements + """ + Post function for /courses/join + students or admins with a join code can join a course + """ + + response = { + "url": f"{API_URL}/courses/join" + } + + data = request.get_json() + if not "join_code" in data: + return {"message": "join_code is required"}, 400 + + join_code = data["join_code"] + share_code = CourseShareCode.query.filter_by(join_code=join_code).first() + + if not share_code: + response["message"] = "Invalid join code" + return response, 400 + + if share_code.expiry_time and share_code.expiry_time < datetime.now(ZoneInfo(TIMEZONE)): + response["message"] = "Join code has expired" + return response, 400 + + + course_id = share_code.course_id + is_for_admins = share_code.for_admins + + course_relation = CourseStudent + if is_for_admins: + course_relation = CourseAdmin + + try: + relation = course_relation.query.filter_by(course_id=course_id, uid=uid).first() + if relation: + response["message"] = "User already in course" + return response, 400 + except SQLAlchemyError: + response["message"] = "Internal server error" + return response, 500 + + course_relation = course_relation(course_id=course_id, uid=uid) + + try: + course_relation.insert() + response["message"] = "User added to course" + return response, 201 + except SQLAlchemyError: + response["message"] = "Internal server error" + return response, 500 From f2de32a945e8b03e273046f6ea6671d2f07e634e Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 18 Apr 2024 20:36:38 +0200 Subject: [PATCH 281/377] Addding a displayname to a user object (#221) --- backend/db_construct.sql | 1 + backend/project/models/user.py | 11 ++++++++--- backend/tests/conftest.py | 22 +++++++++++----------- backend/tests/endpoints/conftest.py | 13 +++++++------ 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/backend/db_construct.sql b/backend/db_construct.sql index cd35fe3f..f9a31e60 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -5,6 +5,7 @@ CREATE TYPE runner AS ENUM ('PYTHON', 'GENERAL', 'CUSTOM'); CREATE TABLE users ( uid VARCHAR(255), + display_name VARCHAR(255), role role NOT NULL, PRIMARY KEY(uid) ); diff --git a/backend/project/models/user.py b/backend/project/models/user.py index 7cd59fd1..7bc9ed30 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -13,12 +13,16 @@ class Role(Enum): @dataclass class User(db.Model): - """This class defines the users table, - a user has a uid and a role, a user - can be either a student,admin or teacher""" + """ + This class defines the users table + a user has a uid, + a display_name and a role, + this role can be either student, admin or teacher + """ __tablename__ = "users" uid: str = Column(String(255), primary_key=True) + display_name: str = Column(String(255)) role: Role = Column(EnumField(Role), nullable=False) def to_dict(self): """ @@ -27,4 +31,5 @@ def to_dict(self): return { 'uid': self.uid, 'role': self.role.name, # Convert the enum to a string + 'display_name': self.display_name } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c031805a..8404d35f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -73,13 +73,13 @@ def auth_tokens(session: Session) -> None: """Add the authenticated users to the database""" session.add_all([ - User(uid="login", role=Role.STUDENT), - User(uid="student", role=Role.STUDENT), - User(uid="student_other", role=Role.STUDENT), - User(uid="teacher", role=Role.TEACHER), - User(uid="teacher_other", role=Role.TEACHER), - User(uid="admin", role=Role.ADMIN), - User(uid="admin_other", role=Role.ADMIN) + User(uid="login", role=Role.STUDENT, display_name="Login User"), + User(uid="student", role=Role.STUDENT, display_name="Student Person"), + User(uid="student_other", role=Role.STUDENT, display_name="Student Other Person"), + User(uid="teacher", role=Role.TEACHER, display_name="Teacher Person"), + User(uid="teacher_other", role=Role.TEACHER, display_name="Teacher Other"), + User(uid="admin", role=Role.ADMIN, display_name="Admin Man"), + User(uid="admin_other", role=Role.ADMIN, display_name="Admin Woman") ]) session.commit() @@ -117,10 +117,10 @@ def db_session(): def users(): """Return a list of users to populate the database""" return [ - User(uid="brinkmann", role=Role.ADMIN), - User(uid="laermans", role=Role.ADMIN), - User(uid="student01", role=Role.STUDENT), - User(uid="student02", role=Role.STUDENT) + User(uid="brinkmann", role=Role.ADMIN, display_name="Gunnar Brinkmann"), + User(uid="laermans", role=Role.ADMIN, display_name="Eric Laermans"), + User(uid="student01", role=Role.STUDENT, display_name="Student Zero One"), + User(uid="student02", role=Role.STUDENT, display_name="Student Zero Two") ] def courses(): diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index b6311936..27ff8be4 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -153,7 +153,8 @@ def valid_user(): """ return { "uid": "w_student", - "role": Role.STUDENT.name + "role": Role.STUDENT.name, + "display_name": "Valid User" } @pytest.fixture @@ -180,10 +181,10 @@ def valid_user_entries(session): Returns a list of users that are in the database """ users = [ - User(uid="del", role=Role.TEACHER), - User(uid="pat", role=Role.TEACHER), - User(uid="u_get", role=Role.TEACHER), - User(uid="query_user", role=Role.ADMIN)] + User(uid="del", role=Role.TEACHER, display_name="Peter Deleter"), + User(uid="pat", role=Role.TEACHER, display_name="Patrick Patcher"), + User(uid="u_get", role=Role.TEACHER, display_name="User Getter"), + User(uid="query_user", role=Role.ADMIN, display_name="Quentin Query")] session.add_all(users) session.commit() @@ -222,7 +223,7 @@ def files(): @pytest.fixture def course_teacher_ad(): """A user that's a teacher for testing""" - ad_teacher = User(uid="Gunnar", role=Role.TEACHER) + ad_teacher = User(uid="Gunnar", role=Role.TEACHER, display_name="Gunnar Brinckmann") return ad_teacher @pytest.fixture From 26302992669db56ddf7d48a652c6bd35fede655f Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Thu, 18 Apr 2024 21:43:53 +0200 Subject: [PATCH 282/377] Homepage student + landing page (#194) Homepages added --- frontend/public/img/logo_app.png | Bin 0 -> 704 bytes frontend/public/locales/en/translation.json | 20 +- frontend/public/locales/nl/translation.json | 23 +- frontend/src/App.tsx | 7 +- frontend/src/pages/home/Home.tsx | 50 ++++- frontend/src/pages/home/HomePage.tsx | 197 ++++++++++++++++++ frontend/src/pages/home/HomePages.tsx | 21 ++ frontend/src/pages/project/FetchProjects.tsx | 114 ++++++++++ .../projectDeadline/ProjectDeadline.tsx | 49 +++++ .../projectDeadline/ProjectDeadlineCard.tsx | 72 +++++++ 10 files changed, 535 insertions(+), 18 deletions(-) create mode 100644 frontend/public/img/logo_app.png create mode 100644 frontend/src/pages/home/HomePage.tsx create mode 100644 frontend/src/pages/home/HomePages.tsx create mode 100644 frontend/src/pages/project/FetchProjects.tsx create mode 100644 frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx create mode 100644 frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx diff --git a/frontend/public/img/logo_app.png b/frontend/public/img/logo_app.png new file mode 100644 index 0000000000000000000000000000000000000000..7a36d43ced7a9641bec3ff2ec4c0607d9bcb853d GIT binary patch literal 704 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&i3a$DxcX!k>UaGFp-wRB`f1Sh zvr;Q*CQt!eNswPKLx@J(-EF4xl_vp3I14-?iy0W0Uw|;<*6N^a1_mZoPZ!6KiaBqi zPER^)z+s&5<G=sCn`yV-D*Gn!yhxHQo~RUQVZ3l_D+I_)Sh0$$(7EBLT)?X5%j<v5 zZpo^vd}{u>2Z*Qs`ExQ~t(EE5Jci^ejBB<#<jOx_S);@J?@;_S#=RR>eR-9s^8T}X zmwTfP=MBBaxN43skMG#dD_MCVAzpUH#RKP=e%(7eVNK(`6B+q>n$!6oS#clfn^?yo za_Zi``z-r4ewvwVV6YPkD3rZ8v7zg=2jBUGMh07<fRBM!_*q5nxTf8Z`mnL#kCZ@E zgA8{-0q2@#?}VSWTZE_7^($7bNIN0*;U=fYI>xz|o?mm&`=EZU!T<63wTyF*&yQl> z=`lZqd0$5TG=_ay^+Eip4R(v$t&YgYG0SbVcVy_j#Qr_t*iYr`1BbsIzt&*$rO{MC zzWz$-k;QMh*D>yV)W5p%$4lj`19iWhvknM<bLUuK`Rk?JDo552Oa5*aykPTp3Pb0E z@7E_he>`8xMMU8L8I5TQRq31-C+gk{KG66*m(xRL?`a0!C*=|c8oyQZEbypuXH}Ut zf8n>=9|Tkmf8w4t-}!d>HO`Xn7XQV%k0x#T-&I@Nx3#`vw!hL%+5M|c`(kYEPtUZe zxOw-coM?WLR9a!axy2bF?T6po|9@Dam(co+?V#EPImrb=8Q*yVIJZ=@8T>o;faTUS zM!m+e(+f%(tMr-ANbGYibWUJS6wc^9$hl?OPK`>=jDL_we|Cg_-ImzD?Fp7yz*NMS f8sVAd>&u`8WOD#92wV!D45B<;{an^LB{Ts5F^D&V literal 0 HcmV?d00001 diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index b9cbb5e1..27bcab24 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -12,7 +12,9 @@ "home": { "home": "Home", "tag": "en", - "homepage": "Homepage" + "homepage": "Homepage", + "welcomeDescription": "Welcome to Peristerónas, the online submission platform of UGent", + "login": "Login" }, "courseForm": { "courseName": "Course Name", @@ -79,5 +81,19 @@ "noRegexPlaceholder": "No regex added yet", "clearSelected": "Clear Selection", "faultySubmission": "Some fields were left open or there is no valid runner/file combination" + }, + "student" : { + "myProjects": "My Projects", + "myCourses": "My Courses", + "deadlines": "Past deadlines", + "last_submission" : "Last submission", + "course": "Course", + "SUCCESS": "Success", + "FAIL": "Fail", + "deadlinesOnDay": "Deadlines on: ", + "noDeadline": "No deadlines", + "no_submission_yet" : "No submission yet", + "loading": "Loading...", + "no_projects": "There are no projects here." } -} \ No newline at end of file +} diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 92be172f..bdd4028f 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -1,7 +1,4 @@ { - "home": { - "title": "Homepagina" - }, "header": { "myProjects": "Mijn Projecten", "myCourses": "Mijn Vakken", @@ -12,16 +9,32 @@ "projectUploadForm": "Project uploaden" }, "home": { - "login": "Aanmelden", "home": "Home", "tag": "nl", - "homepage": "Homepage" + "homepage": "Homepagina", + "welcomeDescription": "Welkom bij Peristerónas, het online indieningsplatform van UGent", + "login": "Aanmelden" }, "courseForm": { "courseName": "Vak Naam", "submit": "Opslaan", "emptyCourseNameError": "Vak naam mag niet leeg zijn" }, + "student": { + "myProjects": "Mijn Projecten", + "myCourses": "Mijn Vakken", + "deadlines": "Verlopen Deadlines", + "course": "Vak", + "last_submission": "Laatste indiening", + "SUCCESS": "Geslaagd", + "FAIL": "Gefaald", + "deadlinesOnDay": "Deadlines op: ", + "noDeadline": "Geen deadlines", + "no_submission_yet" : "Nog geen indiening", + "loading": "Laden...", + "no_projects": "Er zijn hier geen projecten." + + }, "projectForm": { "projectTitle": "Titel", "projectDescription": "Beschrijving", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 881a3dff..b1038469 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,18 @@ import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; import Layout from "./components/Header/Layout"; -import Home from "./pages/home/Home"; import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; import { ErrorBoundary } from "./pages/error/ErrorBoundary.tsx"; import ProjectCreateHome from "./pages/create_project/ProjectCreateHome.tsx"; +import {fetchProjectPage} from "./pages/project/FetchProjects.tsx"; +import HomePages from "./pages/home/HomePages.tsx"; const router = createBrowserRouter( createRoutesFromElements( <Route path="/" element={<Layout />} errorElement={<ErrorBoundary />}> - <Route index element={<Home />} /> + <Route index element={<HomePages />} loader={fetchProjectPage}/> <Route path=":lang" element={<LanguagePath/>}> - <Route path="home" element={<Home />} /> + <Route path="home" element={<HomePages />} loader={fetchProjectPage} /> <Route path="project" > <Route path=":projectId" element={<ProjectView />}/> </Route> diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 53bce51d..54de654d 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,16 +1,50 @@ import { useTranslation } from "react-i18next"; -import { Title } from "../../components/Header/Title"; +import { Button, Container, Typography, Box } from "@mui/material"; +import {Link } from "react-router-dom"; + /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { - const { t } = useTranslation("translation", { keyPrefix: "home" }); + const { t } = useTranslation('translation', { keyPrefix: 'home' }); + const login_redirect:string =import.meta.env.VITE_LOGIN_LINK return ( - <> - <Title title={t('title')} /> - <div> - </div> - </> - ); + <Container maxWidth="sm"> + <Box + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100vh', + textAlign: 'center', + gap: 3, + }} + > + <Box component="img"src="/img/logo_ugent.png" alt="University Logo" + sx={{ width: 100, height: 100 }} /> + + <Typography variant="h2" component="h1" gutterBottom > + <Box + component="img" + src="/img/logo_app.png" + alt="University Logo" + sx={{ + position: 'relative', + top: '14px', + width: 90, + height: 90, + }} + /> + Peristerónas + </Typography> + <Typography variant="h6" component="p" > + {t('welcomeDescription', 'Welcome to Peristeronas.')} + </Typography> + <Button variant="contained" color="primary" size="large" component={Link} to={login_redirect}> + {t('login', 'Login')} + </Button> + </Box> + </Container> ); } diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx new file mode 100644 index 00000000..0fbb9c45 --- /dev/null +++ b/frontend/src/pages/home/HomePage.tsx @@ -0,0 +1,197 @@ +import { useTranslation } from "react-i18next"; +import {Card, CardContent, Typography, Grid, Container, Badge} from '@mui/material'; +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; +import {DayCalendarSkeleton, LocalizationProvider} from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import React, {useState} from 'react'; +import dayjs, {Dayjs} from "dayjs"; +import { PickersDay, PickersDayProps } from '@mui/x-date-pickers/PickersDay'; +import {ProjectDeadlineCard} from "../project/projectDeadline/ProjectDeadlineCard.tsx"; +import {ProjectDeadline} from "../project/projectDeadline/ProjectDeadline.tsx"; +import {useLoaderData} from "react-router-dom"; + +interface DeadlineInfoProps { + selectedDay: Dayjs; + deadlines: ProjectDeadline[]; +} + +type ExtendedPickersDayProps = PickersDayProps<Dayjs> & { highlightedDays?: number[] }; + +/** + * Displays the deadlines on a given day + * @param selectedDay - The day of interest + * @param deadlines - All the deadlines to consider + * @returns Element + */ +const DeadlineInfo: React.FC<DeadlineInfoProps> = ({ selectedDay, deadlines }) => { + const { t } = useTranslation('translation', { keyPrefix: 'student' }); + const deadlinesOnSelectedDay = deadlines.filter( + project => (project.deadline && dayjs(project.deadline).isSame(selectedDay, 'day')) + ); + //list of the corresponding assignment + return ( + <div> + {deadlinesOnSelectedDay.length === 0 ? ( + <Card style={{margin: '10px 0'}}> + <CardContent> + <Typography variant="body1"> + {t('noDeadline')} + </Typography> + </CardContent> + </Card> + ) : <ProjectDeadlineCard deadlines={deadlinesOnSelectedDay}/>} + </div> + ); +}; + +/** + * + * @param props - The day and the deadlines + * @returns - The ServerDay component that displays a badge for specific days + */ +function ServerDay(props: PickersDayProps<Dayjs> & { highlightedDays?: number[] }) { + const { highlightedDays = [], day, outsideCurrentMonth, ...other } = props; + + const isSelected = + !props.outsideCurrentMonth && highlightedDays.indexOf(props.day.date()) >= 0; + + return ( + <Badge + key={props.day.toString()} + overlap="circular" + badgeContent={isSelected ? '🔴' : undefined} + sx={{ + '.MuiBadge-badge': { + fontSize: '0.5em', + top: 8, + right: 8, + }, + }} + > + <PickersDay {...other} outsideCurrentMonth={outsideCurrentMonth} day={day} /> + </Badge> + ); +} +const handleMonthChange =( + date: Dayjs, + projects:ProjectDeadline[], + setHighlightedDays: React.Dispatch<React.SetStateAction<number[]>>, +) => { + + setHighlightedDays([]); + // projects are now only fetched on page load + const hDays:number[] = [] + projects.map((project, ) => { + if(project.deadline && project.deadline.getMonth() == date.month() && project.deadline.getFullYear() == date.year()){ + hDays.push(project.deadline.getDate()) + } + + } + ); + setHighlightedDays(hDays) + +}; + +/** + * This component is the home page component that will be rendered when on the index route. + * @returns - The home page component + */ +export default function HomePage() { + const { t } = useTranslation('translation', { keyPrefix: 'student' }); + + const [highlightedDays, setHighlightedDays] = React.useState<number[]>([]); + + const [selectedDay, setSelectedDay] = useState<Dayjs>(dayjs(Date.now())); + const loader = useLoaderData() as { + projects: ProjectDeadline[], + me: string + } + const projects = loader.projects + + // Update selectedDay state when a day is selected + const handleDaySelect = (day: Dayjs) => { + setSelectedDay(day); + }; + const futureProjects = projects + .filter((p) => (p.deadline && dayjs(dayjs()).isBefore(p.deadline))) + .sort((a, b) => dayjs(a.deadline).diff(dayjs(b.deadline))) + .slice(0, 3) // only show the first 3 + + const pastDeadlines = projects + .filter((p) => p.deadline && (dayjs()).isAfter(p.deadline)) + .sort((a, b) => dayjs(b.deadline).diff(dayjs(a.deadline))) + .slice(0, 3) // only show the first 3 + const noDeadlineProject = projects.filter((p) => p.deadline === undefined) + return ( + <Container style={{ paddingTop: '50px' }}> + <Grid container spacing={2} wrap="nowrap"> + <Grid item xs={6}> + <Card> + <CardContent> + <Typography variant="body1"> + {t('myProjects')} + </Typography> + {futureProjects.length + noDeadlineProject.length > 0? ( + <> + <ProjectDeadlineCard deadlines={futureProjects} /> + <ProjectDeadlineCard deadlines={noDeadlineProject}/> + </> + ) : ( + <Typography variant="body1"> + {t('no_projects')} + </Typography> + )} + </CardContent> + </Card> + </Grid> + + <Grid item xs={6}> + <Card> + + <CardContent> + <Typography variant="body1"> + {t('deadlines')} + </Typography> + {pastDeadlines.length > 0 ? ( + <ProjectDeadlineCard deadlines={pastDeadlines} /> + ) : ( + <Typography variant="body1"> + {t('no_projects')} + </Typography> + )} + </CardContent> + </Card> + </Grid> + + <Grid item xs={6}> + <Card> + <LocalizationProvider dateAdapter={AdapterDayjs}> + <DateCalendar + value={selectedDay} + onMonthChange={(date: Dayjs) => { handleMonthChange(date, projects, setHighlightedDays) }} + onChange={handleDaySelect} + renderLoading={() => <DayCalendarSkeleton />} + slots={{ + day: ServerDay, + }} + slotProps={{ + day: { + highlightedDays, + } as ExtendedPickersDayProps, + }} + /> + </LocalizationProvider> + <CardContent> + <Typography variant="body2"> + {t('deadlinesOnDay')} {selectedDay.format('MMMM D, YYYY')} + </Typography> + <DeadlineInfo selectedDay={selectedDay} deadlines={projects} /> + </CardContent> + + </Card> + </Grid> + + </Grid> + </Container> + ); +} diff --git a/frontend/src/pages/home/HomePages.tsx b/frontend/src/pages/home/HomePages.tsx new file mode 100644 index 00000000..140be48c --- /dev/null +++ b/frontend/src/pages/home/HomePages.tsx @@ -0,0 +1,21 @@ +import HomePage from './HomePage.tsx'; +import Home from "./Home.tsx"; +import {useLoaderData} from "react-router-dom"; +import {ProjectDeadline} from "../project/projectDeadline/ProjectDeadline.tsx"; + +/** + * Gives the requested home page based on the login status + * @returns - The home page component + */ +export default function HomePages() { + const loader = useLoaderData() as { + projects: ProjectDeadline[], + me: string + } + const me = loader.me + if (me === 'UNKNOWN') { + return <Home />; + } else { + return <HomePage />; + } +} diff --git a/frontend/src/pages/project/FetchProjects.tsx b/frontend/src/pages/project/FetchProjects.tsx new file mode 100644 index 00000000..1b988825 --- /dev/null +++ b/frontend/src/pages/project/FetchProjects.tsx @@ -0,0 +1,114 @@ +import {Project, ProjectDeadline, ShortSubmission} from "./projectDeadline/ProjectDeadline.tsx"; +const API_URL = import.meta.env.VITE_APP_API_HOST +const header = { + "Authorization": "teacher2" +} +export const fetchProjectPage = async () => { + const projects = await fetchProjects() + const me = await fetchMe() + return {projects, me} +} + +export const fetchMe = async () => { + try { + const response = await fetch(`${API_URL}/me`, { + headers:header + }) + if(response.status == 200){ + const data = await response.json() + return data.role + }else { + return "UNKNOWN" + } + } catch (e){ + return "UNKNOWN" + } + +} +export const fetchProjects = async () => { + + try{ + const response = await fetch(`${API_URL}/projects`, { + headers:header + }) + const jsonData = await response.json(); + let formattedData: ProjectDeadline[] = await Promise.all( jsonData.data.map(async (item:Project) => { + try{ + const url_split = item.project_id.split('/') + const project_id = url_split[url_split.length -1] + const response_submissions = await (await fetch(encodeURI(`${API_URL}/submissions?project_id=${project_id}`), { + headers: header + })).json() + + //get the latest submission + const latest_submission = response_submissions.data.map((submission:ShortSubmission) => ({ + submission_id: submission.submission_id,//this is the path + submission_time: new Date(submission.submission_time), + submission_status: submission.submission_status + } + )).sort((a:ShortSubmission, b:ShortSubmission) => b.submission_time.getTime() - a.submission_time.getTime())[0]; + // fetch the course id of the project + const project_item = await (await fetch(encodeURI(`${API_URL}/projects/${project_id}`), { + headers:header + })).json() + + //fetch the course + const response_courses = await (await fetch(encodeURI(`${API_URL}/courses/${project_item.data.course_id}`), { + headers: header + })).json() + const course = { + course_id: response_courses.data.course_id, + name: response_courses.data.name, + teacher: response_courses.data.teacher, + ufora_id: response_courses.data.ufora_id + } + if(project_item.data.deadlines){ + return project_item.data.deadlines.map((d:string[]) => { + return { + project_id: project_id, + title: project_item.data.title, + description: project_item.data.description, + assignment_file: project_item.data.assignment_file, + deadline: new Date(d[1]), + deadline_description: d[0], + course_id: Number(project_item.data.course_id), + visible_for_students: Boolean(project_item.data.visible_for_students), + archived: Boolean(project_item.data.archived), + test_path: project_item.data.test_path, + script_name: project_item.data.script_name, + regex_expressions: project_item.data.regex_expressions, + short_submission: latest_submission, + course: course + } + }) + } + // contains no dealine: + return [{ + project_id: project_id, + title: project_item.data.title, + description: project_item.data.description, + assignment_file: project_item.data.assignment_file, + deadline: undefined, + deadline_description: undefined, + course_id: Number(project_item.data.course_id), + visible_for_students: Boolean(project_item.data.visible_for_students), + archived: Boolean(project_item.data.archived), + test_path: project_item.data.test_path, + script_name: project_item.data.script_name, + regex_expressions: project_item.data.regex_expressions, + short_submission: latest_submission, + course: course + }] + + }catch (e){ + return [] + } + } + + )); + formattedData = formattedData.flat() + return formattedData + } catch (e) { + return [] + } +} diff --git a/frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx b/frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx new file mode 100644 index 00000000..50f16a60 --- /dev/null +++ b/frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx @@ -0,0 +1,49 @@ +export interface ProjectDeadline { + project_id:string , + title :string, + description:string, + assignment_file:string, + deadline:Date|undefined, + deadline_description:string, + course_id:number, + visible_for_students:boolean, + archived:boolean, + test_path:string, + script_name:string, + regex_expressions:string[], + short_submission: ShortSubmission, + course:Course + +} +export interface Project { + project_id:string , + title :string, + description:string, + assignment_file:string, + deadlines:string[][], + course_id:number, + visible_for_students:boolean, + archived:boolean, + test_path:string, + script_name:string, + regex_expressions:string[], + short_submission: ShortSubmission, + course:Course + +} +export interface Deadline { + description: string; + deadline: Date; +} + +export interface Course { + course_id: string; + name: string; + teacher: string; + ufora_id: string; +} +export interface ShortSubmission { + submission_id:number, + submission_time:Date, + submission_status:string +} diff --git a/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx new file mode 100644 index 00000000..56d2351d --- /dev/null +++ b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx @@ -0,0 +1,72 @@ +import {CardActionArea, Card, CardContent, Typography, Box, Button} from '@mui/material'; +import {Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import dayjs from "dayjs"; +import {ProjectDeadline, Deadline} from "./ProjectDeadline.tsx"; +import React from "react"; +import { useNavigate } from 'react-router-dom'; + +interface ProjectCardProps{ + deadlines:ProjectDeadline[], + pred?: (deadline:Deadline) => boolean +} + +/** + * A clickable display of a project deadline + * @param deadlines - A list of all the deadlines + * @param pred - A predicate to filter the deadlines + * @returns Element + */ +export const ProjectDeadlineCard: React.FC<ProjectCardProps> = ({ deadlines }) => { + const { t } = useTranslation('translation', { keyPrefix: 'student' }); + const { i18n } = useTranslation(); + const navigate = useNavigate(); + + //list of the corresponding assignment + return ( + <Box> + {deadlines.map((project, index) => ( + + <Card key={index} style={{margin: '10px 0'}}> + <CardActionArea component={Link} to={`/${i18n.language}/projects/${project.project_id}`}> + <CardContent> + <Typography variant="h6" style={{color: project.short_submission ? + (project.short_submission.submission_status === 'SUCCESS' ? 'green' : 'red') : '#686868'}}> + {project.title} + </Typography> + <Typography variant="subtitle1"> + {t('course')}: + <Button + style={{ + color: 'inherit', + textTransform: 'none' + }} + onMouseDown={event => event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); // stops the event from reaching CardActionArea + event.preventDefault(); + navigate(`/${i18n.language}/courses/${project.course.course_id}`) + }} + > + {project.course.name} + </Button> + </Typography> + <Typography variant="body2" color="textSecondary"> + {t('last_submission')}: {project.short_submission ? + t(project.short_submission.submission_status.toString()) : t('no_submission_yet')} + </Typography> + {project.deadline && ( + <Typography variant="body2" color="textSecondary"> + Deadline: {dayjs(project.deadline).format('MMMM D, YYYY')} + </Typography> + )} + + </CardContent> + </CardActionArea> + </Card> + )) + + } + </Box> + ); +}; From 99ac4455eb3a73eb001ba46affce89712b23bbad Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 18 Apr 2024 21:43:59 +0200 Subject: [PATCH 283/377] Full authentication (#216) * backend-authentication * fix projects * remove extra requirements * fix test-auth_server * added: frontend redirect link * fix backend auth * fix error + code_challenge in frontend link * line too long + frontend linter errors * actual linter fix * removed code_verifier until milestone 3 * added ability to log out and added everything to init * redis added later * added login button to header * linter fix unused import * add display_name to user creation * test tests * linter fix * cookies test * flask test cookies? * added logout button * with client --- backend/Dockerfile_auth_test | 2 +- ...ments.txt => auth_server_requirements.txt} | 0 backend/project/__init__.py | 26 +++- .../project/endpoints/authentication/auth.py | 125 ++++++++++++++++++ .../endpoints/authentication/logout.py | 28 ++++ .../project/endpoints/authentication/me.py | 33 +++++ .../project/endpoints/projects/projects.py | 2 +- backend/project/init_auth.py | 56 ++++++++ backend/project/utils/authentication.py | 71 ++-------- backend/requirements.txt | 1 + backend/test_auth_server/__main__.py | 1 - backend/tests.yaml | 2 + backend/tests/endpoints/project_test.py | 24 ++-- frontend/src/components/Header/Header.tsx | 6 +- frontend/src/components/Header/Login.tsx | 16 +++ frontend/src/components/Header/Logout.tsx | 14 ++ 16 files changed, 328 insertions(+), 79 deletions(-) rename backend/{auth_requirements.txt => auth_server_requirements.txt} (100%) create mode 100644 backend/project/endpoints/authentication/auth.py create mode 100644 backend/project/endpoints/authentication/logout.py create mode 100644 backend/project/endpoints/authentication/me.py create mode 100644 backend/project/init_auth.py create mode 100644 frontend/src/components/Header/Login.tsx create mode 100644 frontend/src/components/Header/Logout.tsx diff --git a/backend/Dockerfile_auth_test b/backend/Dockerfile_auth_test index d7541b0d..3653f2b8 100644 --- a/backend/Dockerfile_auth_test +++ b/backend/Dockerfile_auth_test @@ -2,7 +2,7 @@ FROM python:3.9 RUN mkdir /auth-app WORKDIR /auth-app ADD ./test_auth_server /auth-app/ -COPY auth_requirements.txt /auth-app/requirements.txt +COPY auth_server_requirements.txt /auth-app/requirements.txt RUN pip3 install -r requirements.txt COPY . /auth-app ENTRYPOINT ["python"] diff --git a/backend/auth_requirements.txt b/backend/auth_server_requirements.txt similarity index 100% rename from backend/auth_requirements.txt rename to backend/auth_server_requirements.txt diff --git a/backend/project/__init__.py b/backend/project/__init__.py index fe9be2e4..434980df 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -1,8 +1,13 @@ """ Flask API base file This file is the base of the Flask API. It contains the basic structure of the API. """ +from os import getenv +from datetime import timedelta + +from dotenv import load_dotenv from flask import Flask +from flask_jwt_extended import JWTManager from flask_cors import CORS from sqlalchemy_utils import register_composites from .executor import executor @@ -14,6 +19,13 @@ from .endpoints.submissions.submission_config import submissions_bp from .endpoints.courses.join_codes.join_codes_config import join_codes_bp from .endpoints.docs.docs_endpoint import swagger_ui_blueprint +from .endpoints.authentication.auth import auth_bp +from .endpoints.authentication.me import me_bp +from .endpoints.authentication.logout import logout_bp +from .init_auth import auth_init + +load_dotenv() +JWT_SECRET_KEY = getenv("JWT_SECRET_KEY") def create_app(): """ @@ -23,6 +35,13 @@ def create_app(): """ app = Flask(__name__) + app.config["JWT_COOKIE_SECURE"] = True + app.config["JWT_COOKIE_CSRF_PROTECT"] = True + app.config["JWT_TOKEN_LOCATION"] = ["cookies"] + app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY + app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=3) + app.config["JWT_ACCESS_COOKIE_NAME"] = "peristeronas_access_token" + app.config["JWT_SESSION_COOKIE"] = False executor.init_app(app) app.register_blueprint(index_bp) app.register_blueprint(users_bp) @@ -31,7 +50,12 @@ def create_app(): app.register_blueprint(submissions_bp) app.register_blueprint(join_codes_bp) app.register_blueprint(swagger_ui_blueprint) + app.register_blueprint(auth_bp) + app.register_blueprint(me_bp) + app.register_blueprint(logout_bp) + jwt = JWTManager(app) + auth_init(jwt, app) return app def create_app_with_db(db_uri: str): @@ -52,5 +76,5 @@ def create_app_with_db(db_uri: str): # Getting a connection from the scoped session connection = db.session.connection() register_composites(connection) - CORS(app) + CORS(app, supports_credentials=True) return app diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py new file mode 100644 index 00000000..ab59ef5b --- /dev/null +++ b/backend/project/endpoints/authentication/auth.py @@ -0,0 +1,125 @@ +"""Auth api endpoint""" +from os import getenv + +from dotenv import load_dotenv +import requests +from flask import Blueprint, request, redirect, abort, make_response +from flask_jwt_extended import create_access_token, set_access_cookies +from flask_restful import Resource, Api +from sqlalchemy.exc import SQLAlchemyError + +from project import db + +from project.models.user import User, Role + +auth_bp = Blueprint("auth", __name__) +auth_api = Api(auth_bp) + +load_dotenv() +API_URL = getenv("API_HOST") +AUTH_METHOD = getenv("AUTH_METHOD") +AUTHENTICATION_URL = getenv("AUTHENTICATION_URL") +CLIENT_ID = getenv("CLIENT_ID") +CLIENT_SECRET = getenv("CLIENT_SECRET") +HOMEPAGE_URL = getenv("HOMEPAGE_URL") +TENANT_ID = getenv("TENANT_ID") + +def microsoft_authentication(): + """ + This function will handle a microsoft based login, + creating a new user profile in the process and + return a valid access token as a cookie. + Redirects to the homepage of the website + """ + code = request.args.get("code") + if code is None: + return {"message":"This endpoint is only used for authentication."}, 400 + # got code from microsoft + data = {"client_id":CLIENT_ID, + "scope":".default", + "code":code, + "redirect_uri":f"{API_URL}/auth", + "grant_type":"authorization_code", + "client_secret":CLIENT_SECRET} + try: + res = requests.post(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token", + data=data, + timeout=5) + if res.status_code != 200: + abort(make_response(( + {"message": + "An error occured while trying to authenticate your authorization code"}, + 500))) + token = res.json()["access_token"] + profile_res = requests.get("https://graph.microsoft.com/v1.0/me", + headers={"Authorization":f"Bearer {token}"}, + timeout=5) + except TimeoutError: + return {"message":"Request to Microsoft timed out"}, 500 + if not profile_res or profile_res.status_code != 200: + abort(make_response(({"message": + "An error occured while trying to authenticate your access token"}, + 500))) + auth_user_id = profile_res.json()["id"] + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": + "An unexpected database error occured while fetching the user"}, + 500))) + + if not user: + role = Role.STUDENT + if profile_res.json()["jobTitle"] is not None: + role = Role.TEACHER + + # add user if not yet in database + try: + new_user = User(uid=auth_user_id, + role=role, + display_name=profile_res.json()["displayName"]) + db.session.add(new_user) + db.session.commit() + user = new_user + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": + """An unexpected database error occured + while creating the user during authentication"""}, 500))) + resp = redirect(HOMEPAGE_URL, code=303) + additional_claims = {"is_teacher":user.role == Role.TEACHER, + "is_admin":user.role == Role.ADMIN} + set_access_cookies(resp, + create_access_token(identity=profile_res.json()["id"], + additional_claims=additional_claims)) + return resp + + +def test_authentication(): + """ + This function will handle the logins done using our + own authentication server for testing purposes + """ + code = request.args.get("code") + if code is None: + return {"message":"Not yet"}, 500 + profile_res = requests.get(AUTHENTICATION_URL, headers={"Authorization":f"{code}"}, timeout=5) + resp = redirect(HOMEPAGE_URL, code=303) + set_access_cookies(resp, create_access_token(identity=profile_res.json()["id"])) + return resp + + +class Auth(Resource): + """Api endpoint for the /auth route""" + + def get(self): + """ + Will handle the request according to the method defined in the .env variables. + Currently only Microsoft and our test authentication are supported + """ + if AUTH_METHOD == "Microsoft": + return microsoft_authentication() + return test_authentication() + +auth_api.add_resource(Auth, "/auth") diff --git a/backend/project/endpoints/authentication/logout.py b/backend/project/endpoints/authentication/logout.py new file mode 100644 index 00000000..e629bbe3 --- /dev/null +++ b/backend/project/endpoints/authentication/logout.py @@ -0,0 +1,28 @@ +"""Api endpoint to handle logout requests""" +from os import getenv + +from dotenv import load_dotenv +from flask import Blueprint, redirect +from flask_jwt_extended import unset_jwt_cookies, jwt_required +from flask_restful import Resource, Api + +logout_bp = Blueprint("logout", __name__) +logout_api = Api(logout_bp) + +load_dotenv() +HOMEPAGE_URL = getenv("HOMEPAGE_URL") + +class Logout(Resource): + """Api endpoint for the /auth route""" + + @jwt_required() + def get(self): + """ + Will handle the request according to the method defined in the .env variables. + Currently only Microsoft and our test authentication are supported + """ + resp = redirect(HOMEPAGE_URL, 303) + unset_jwt_cookies(resp) + return resp + +logout_api.add_resource(Logout, "/logout") diff --git a/backend/project/endpoints/authentication/me.py b/backend/project/endpoints/authentication/me.py new file mode 100644 index 00000000..c4f19a70 --- /dev/null +++ b/backend/project/endpoints/authentication/me.py @@ -0,0 +1,33 @@ +"""User info api endpoint""" +from os import getenv + +from dotenv import load_dotenv +from flask import Blueprint +from flask_jwt_extended import get_jwt_identity, jwt_required +from flask_restful import Resource, Api + +from project.models.user import User +from project.utils.query_agent import query_by_id_from_model + +load_dotenv() +API_URL = getenv("API_HOST") + +me_bp = Blueprint("me", __name__) +me_api = Api(me_bp) + +class Me(Resource): + """Api endpoint for the /user_info route""" + + @jwt_required() + def get(self): + """ + Will return all user data associated with the access token in the request + """ + uid = get_jwt_identity + + return query_by_id_from_model(User, + "uid", + uid, + "Could not find you in the database, please log in again") + +me_api.add_resource(Me, "/me") diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index ae05894f..5f882c76 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -106,8 +106,8 @@ def post(self, teacher_id=None): if status_code == 400: return new_project, status_code - project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") + project_upload_directory = os.path.join(f"{UPLOAD_FOLDER}", f"{new_project.project_id}") os.makedirs(project_upload_directory, exist_ok=True) if filename is not None: try: diff --git a/backend/project/init_auth.py b/backend/project/init_auth.py new file mode 100644 index 00000000..bc20c497 --- /dev/null +++ b/backend/project/init_auth.py @@ -0,0 +1,56 @@ +""" This file will change the JWT return messages to custom messages + and make it so the access tokens implicitly refresh +""" +from datetime import timedelta, timezone, datetime + +from flask_jwt_extended import get_jwt, get_jwt_identity,\ + create_access_token, set_access_cookies + +def auth_init(jwt, app): + """ + This function will overwrite the default return messages from + the flask-jwt-extended package with custom messages + and make it so the access tokens implicitly refresh + """ + @jwt.expired_token_loader + def expired_token_callback(jwt_header, jwt_payload): + return ( + {"message":"Your access token cookie has expired, please log in again"}, + 401) + + @jwt.invalid_token_loader + def invalid_token_callback(jwt_header, jwt_payload): + return ( + {"message":("The server cannot recognize this access token cookie, " + "please log in again if you think this is an error")}, + 401) + + @jwt.revoked_token_loader + def revoked_token_callback(jwt_header, jwt_payload): + return ( + {"message":("This access token cookie has been revoked, " + "possibly from logging out. Log in again to receive a new access token")}, + 401) + + @jwt.unauthorized_loader + def unauthorized_callback(jwt_header): + return {"message":"You need an access token to get this data, please log in"}, 401 + + @app.after_request + def refresh_expiring_jwts(response): + try: + exp_timestamp = get_jwt()["exp"] + now = datetime.now(timezone.utc) + target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) + if target_timestamp > exp_timestamp: + access_token = create_access_token( + identity=get_jwt_identity(), + additional_claims= + {"is_admin":get_jwt()["is_admin"], + "is_teacher":get_jwt()["is_teacher"]} + ) + set_access_cookies(response, access_token) + return response + except (RuntimeError, KeyError): + # Case where there is not a valid JWT. Just return the original response + return response diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index ad9ba85f..b59ff281 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -8,17 +8,13 @@ from dotenv import load_dotenv from flask import abort, request, make_response -import requests -from sqlalchemy.exc import SQLAlchemyError +from flask_jwt_extended import get_jwt, get_jwt_identity, verify_jwt_in_request -from project import db - -from project.models.user import User, Role from project.utils.models.course_utils import is_admin_of_course, \ is_student_of_course, is_teacher_of_course from project.utils.models.project_utils import get_course_of_project, project_visible from project.utils.models.submission_utils import get_submission, get_course_of_submission -from project.utils.models.user_utils import is_admin, is_teacher +from project.utils.models.user_utils import get_user load_dotenv() API_URL = getenv("API_HOST") @@ -34,58 +30,13 @@ def wrap(*args, **kwargs): def return_authenticated_user_id(): - """This function will authenticate the request and check whether the authenticated user - is already in the database, if not, they will be added + """This function will authenticate the request and ensure the user was added to the database, + otherwise it will prompt them to login again """ - authentication = request.headers.get("Authorization") - if not authentication: - abort( - make_response(( - {"message": - "No authorization given, you need an access token to use this API"}, - 401))) - - auth_header = {"Authorization": authentication} - try: - response = requests.get( - AUTHENTICATION_URL, headers=auth_header, timeout=5) - except TimeoutError: - abort(make_response( - ({"message": "Request to Microsoft timed out"}, 500))) - if not response or response.status_code != 200: - abort(make_response(({"message": - "An error occured while trying to authenticate your access token"}, - 401))) - - user_info = response.json() - auth_user_id = user_info["id"] - try: - user = db.session.get(User, auth_user_id) - except SQLAlchemyError: - db.session.rollback() - abort(make_response(({"message": - "An unexpected database error occured while fetching the user"}, - 500))) - - if user: - return auth_user_id - - # Use the Enum here - role = Role.STUDENT - if user_info["jobTitle"] is not None: - role = Role.TEACHER - - # add user if not yet in database - try: - new_user = User(uid=auth_user_id, role=role) - db.session.add(new_user) - db.session.commit() - except SQLAlchemyError: - db.session.rollback() - abort(make_response(({"message": - """An unexpected database error occured - while creating the user during authentication"""}, 500))) - return auth_user_id + verify_jwt_in_request() + uid = get_jwt_identity() + get_user(uid) + return uid def login_required(f): @@ -118,8 +69,8 @@ def authorize_admin(f): """ @wraps(f) def wrap(*args, **kwargs): - auth_user_id = return_authenticated_user_id() - if is_admin(auth_user_id): + return_authenticated_user_id() + if get_jwt()["is_admin"]: return f(*args, **kwargs) abort(make_response(({"message": """You are not authorized to perfom this action, @@ -135,7 +86,7 @@ def authorize_teacher(f): @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() - if is_teacher(auth_user_id): + if get_jwt()["is_teacher"]: kwargs["teacher_id"] = auth_user_id return f(*args, **kwargs) abort(make_response(({"message": diff --git a/backend/requirements.txt b/backend/requirements.txt index 9b016df4..12a02f7e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ flask~=3.0.2 flask-cors +flask-jwt-extended flask-restful flask-sqlalchemy sqlalchemy_utils diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index adaea5b8..fe981a74 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -1,5 +1,4 @@ """Main entry point for the application.""" - from dotenv import load_dotenv from flask import Flask, Blueprint, request from flask_restful import Resource, Api diff --git a/backend/tests.yaml b/backend/tests.yaml index 5c232c18..f2c2c82e 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -43,6 +43,8 @@ services: POSTGRES_DB: test_database API_HOST: http://api_is_here AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose + AUTH_METHOD: test + JWT_SECRET_KEY: Test123 UPLOAD_URL: /data/assignments DOCS_JSON_PATH: static/OpenAPI_Object.yaml DOCS_URL: /docs diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index a65aa38c..c750cd56 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -11,18 +11,18 @@ def test_assignment_download(client, valid_project): with open("tests/resources/testzip.zip", "rb") as zip_file: valid_project["assignment_file"] = zip_file # post the project - response = client.post( - "/projects", - data=valid_project, - content_type='multipart/form-data', - headers={"Authorization":"teacher"} - ) - assert response.status_code == 201 - project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignment", - headers={"Authorization":"teacher"}) - # 404 because the file is not found, no assignment.md in zip file - assert response.status_code == 404 + with client: + response = client.get("/auth?code=teacher") + response = client.post( + "/projects", + data=valid_project, + content_type='multipart/form-data', + ) + assert response.status_code == 201 + project_id = response.json["data"]["project_id"] + response = client.get(f"/projects/{project_id}/assignment") + # 404 because the file is not found, no assignment.md in zip file + assert response.status_code == 404 def test_not_found_download(client): diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index d807c5b8..b15d188c 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,7 +1,6 @@ import { AppBar, Box, - Button, IconButton, Menu, MenuItem, @@ -19,6 +18,7 @@ import { useEffect, useState } from "react"; import LanguageIcon from "@mui/icons-material/Language"; import { Link } from "react-router-dom"; import { TitlePortal } from "./TitlePortal"; +import {LoginButton} from "./Login"; /** * The header component for the application that will be rendered at the top of the page. @@ -68,8 +68,8 @@ export function Header(): JSX.Element { <IconButton edge="start" onClick={() => setOpen(!open)} sx={{ color: "white", marginLeft: 0 }}> <MenuIcon style={{fontSize:"2rem"}} /> </IconButton> - <TitlePortal /> - <Button color="inherit">{t("login")}</Button> + <TitlePortal/> + <LoginButton></LoginButton> <div> <IconButton onClick={handleLanguageMenu} color="inherit"> <LanguageIcon /> diff --git a/frontend/src/components/Header/Login.tsx b/frontend/src/components/Header/Login.tsx new file mode 100644 index 00000000..d06bf021 --- /dev/null +++ b/frontend/src/components/Header/Login.tsx @@ -0,0 +1,16 @@ +import {Button} from "@mui/material"; +import { Link } from 'react-router-dom'; + +const CLIENT_ID = import.meta.env.VITE_APP_CLIENT_ID; +const REDIRECT_URI = encodeURI(import.meta.env.VITE_APP_API_HOST + "/auth"); +const TENANT_ID = import.meta.env.VITE_APP_TENANT_ID; + +/** + * The login component for the application that will redirect to the correct login link. + * @returns - A login button + */ +export function LoginButton(): JSX.Element { + const link = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize?prompt=select_account&response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=.default`; + + return <Button variant="contained" component={Link} to={link}>Login</Button> +} diff --git a/frontend/src/components/Header/Logout.tsx b/frontend/src/components/Header/Logout.tsx new file mode 100644 index 00000000..2e569bd0 --- /dev/null +++ b/frontend/src/components/Header/Logout.tsx @@ -0,0 +1,14 @@ +import {Button} from "@mui/material"; +import {Link} from 'react-router-dom'; + +const API_HOST = import.meta.env.VITE_APP_API_HOST; + +/** + * The Logout component for the application that will redirect to the correct logout link. + * @returns - A Logout button + */ +export function LogoutButton(): JSX.Element { + const link = `${API_HOST}/logout`; + + return <Button variant="contained" component={Link} to={link}>Log out</Button> +} \ No newline at end of file From c5e9fbc728e0d9dd1c37090b17ab363e9989d532 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 18 Apr 2024 21:56:46 +0200 Subject: [PATCH 284/377] Project overview (#219) * goeie styling yallah * pls werk * werkt hopelijk * linter --------- Co-authored-by: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> --- .../endpoints/projects/project_endpoint.py | 6 + .../projects/project_last_submission.py | 24 ++++ .../projects/project_submissions_download.py | 71 ++++++----- .../endpoints/submissions/submissions.py | 1 + frontend/package-lock.json | 13 ++ frontend/package.json | 2 + .../public/locales/en/submissionOverview.json | 6 + .../locales/nl/submissionsOverview.json | 6 + frontend/src/App.tsx | 7 +- .../components/ProjectForm/ProjectForm.tsx | 2 +- .../ProjectSubmissionOverview.tsx | 64 ++++++++++ .../ProjectSubmissionOverviewDatagrid.tsx | 111 ++++++++++++++++++ .../SubmissionsOverview.tsx | 17 +++ 13 files changed, 297 insertions(+), 33 deletions(-) create mode 100644 backend/project/endpoints/projects/project_last_submission.py create mode 100644 frontend/public/locales/en/submissionOverview.json create mode 100644 frontend/public/locales/nl/submissionsOverview.json create mode 100644 frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx create mode 100644 frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx create mode 100644 frontend/src/pages/submission_overview/SubmissionsOverview.tsx diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index 0aede0ff..6fa7510a 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -9,6 +9,7 @@ from project.endpoints.projects.project_detail import ProjectDetail from project.endpoints.projects.project_assignment_file import ProjectAssignmentFiles from project.endpoints.projects.project_submissions_download import SubmissionDownload +from project.endpoints.projects.project_last_submission import SubmissionPerUser project_bp = Blueprint('project_endpoint', __name__) @@ -32,3 +33,8 @@ '/projects/<int:project_id>/submissions-download', view_func=SubmissionDownload.as_view('project_submissions') ) + +project_bp.add_url_rule( + '/projects/<int:project_id>/latest-per-user', + view_func=SubmissionPerUser.as_view('latest_per_user') +) diff --git a/backend/project/endpoints/projects/project_last_submission.py b/backend/project/endpoints/projects/project_last_submission.py new file mode 100644 index 00000000..5b998c25 --- /dev/null +++ b/backend/project/endpoints/projects/project_last_submission.py @@ -0,0 +1,24 @@ +""" +This module gives the last submission for a project for every user +""" + +from os import getenv +from urllib.parse import urljoin +from flask_restful import Resource +from project.endpoints.projects.project_submissions_download import get_last_submissions_per_user + +API_HOST = getenv("API_HOST") +UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") +BASE_URL = urljoin(f"{API_HOST}/", "/projects") + +class SubmissionPerUser(Resource): + """ + Recourse to get all the submissions for users + """ + + def get(self, project_id: int): + """ + Download all submissions for a project as a zip file. + """ + + return get_last_submissions_per_user(project_id) diff --git a/backend/project/endpoints/projects/project_submissions_download.py b/backend/project/endpoints/projects/project_submissions_download.py index d59a8ca2..8e6ec83e 100644 --- a/backend/project/endpoints/projects/project_submissions_download.py +++ b/backend/project/endpoints/projects/project_submissions_download.py @@ -19,6 +19,43 @@ UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") BASE_URL = urljoin(f"{API_HOST}/", "/projects") +def get_last_submissions_per_user(project_id): + """ + Get the last submissions per user for a given project + """ + try: + project = Project.query.get(project_id) + except SQLAlchemyError: + return {"message": "Internal server error"}, 500 + + if project is None: + return { + "message": f"Project (project_id={project_id}) not found", + "url": BASE_URL}, 404 + + # Define a subquery to find the latest submission times for each user + latest_submissions = db.session.query( + Submission.uid, + func.max(Submission.submission_time).label('max_time') + ).filter( + Submission.project_id == project_id, + Submission.submission_status != 'LATE' + ).group_by( + Submission.uid + ).subquery() + + # Use the subquery to fetch the actual submissions + submissions = db.session.query(Submission).join( + latest_submissions, + (Submission.uid == latest_submissions.c.uid) & + (Submission.submission_time == latest_submissions.c.max_time) + ).all() + + if not submissions: + return {"message": "No submissions found", "url": BASE_URL}, 404 + + return {"message": "Resource fetched succesfully", "data": submissions}, 200 + class SubmissionDownload(Resource): """ Resource to download all submissions for a project. @@ -27,37 +64,11 @@ def get(self, project_id: int): """ Download all submissions for a project as a zip file. """ + data, status_code = get_last_submissions_per_user(project_id) - try: - project = Project.query.get(project_id) - except SQLAlchemyError: - return {"message": "Internal server error"}, 500 - - if project is None: - return { - "message": f"Project (project_id={project_id}) not found", - "url": BASE_URL}, 404 - - # Define a subquery to find the latest submission times for each user - latest_submissions = db.session.query( - Submission.uid, - func.max(Submission.submission_time).label('max_time') - ).filter( - Submission.project_id == project_id, - Submission.submission_status != 'LATE' - ).group_by( - Submission.uid - ).subquery() - - # Use the subquery to fetch the actual submissions - submissions = db.session.query(Submission).join( - latest_submissions, - (Submission.uid == latest_submissions.c.uid) & - (Submission.submission_time == latest_submissions.c.max_time) - ).all() - - if not submissions: - return {"message": "No submissions found", "url": BASE_URL}, 404 + if status_code != 200: + return data, status_code + submissions = data["data"] def zip_directory_stream(): with io.BytesIO() as memory_file: diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index 89a4d1bb..c5e9603b 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -24,6 +24,7 @@ from project.utils.submissions.evaluator import run_evaluator from project.utils.models.project_utils import get_course_of_project + API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") BASE_URL = urljoin(f"{API_HOST}/", "/submissions") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d9a738d..1674097e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@mui/x-date-pickers": "^7.1.1", "axios": "^1.6.8", "dayjs": "^1.11.10", + "downloadjs": "^1.4.7", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", "jszip": "^3.10.1", @@ -31,6 +32,7 @@ }, "devDependencies": { "@types/history": "^4.7.11", + "@types/downloadjs": "^1.4.6", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@types/react-router-dom": "^5.3.3", @@ -1789,6 +1791,12 @@ "@types/ms": "*" } }, + "node_modules/@types/downloadjs": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/downloadjs/-/downloadjs-1.4.6.tgz", + "integrity": "sha512-mp3w70vsaiLRT9ix92fmI9Ob2yJAPZm6tShJtofo2uHbN11G2i6a0ApIEjBl/kv3e9V7Pv7jMjk1bUwYWvMHvA==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -3582,6 +3590,11 @@ "csstype": "^3.0.2" } }, + "node_modules/downloadjs": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", + "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==" + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7ea862ec..f866c3cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@mui/x-date-pickers": "^7.1.1", "axios": "^1.6.8", "dayjs": "^1.11.10", + "downloadjs": "^1.4.7", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", "jszip": "^3.10.1", @@ -34,6 +35,7 @@ "styled-components": "^6.1.8" }, "devDependencies": { + "@types/downloadjs": "^1.4.6", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@types/react-router-dom": "^5.3.3", diff --git a/frontend/public/locales/en/submissionOverview.json b/frontend/public/locales/en/submissionOverview.json new file mode 100644 index 00000000..44ad27d3 --- /dev/null +++ b/frontend/public/locales/en/submissionOverview.json @@ -0,0 +1,6 @@ +{ + "submissionOverview": { + "submissionOverviewHeader": "Project status overview", + "downloadButton": "DOWNLOAD ALL PROJECTS" + } +} \ No newline at end of file diff --git a/frontend/public/locales/nl/submissionsOverview.json b/frontend/public/locales/nl/submissionsOverview.json new file mode 100644 index 00000000..1a2e172b --- /dev/null +++ b/frontend/public/locales/nl/submissionsOverview.json @@ -0,0 +1,6 @@ +{ + "submissionOverview": { + "submissionOverviewHeader": "Project status overzicht", + "downloadButton": "DOWNLOAD ALLE PROJECTEN" + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b1038469..c3e9a296 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; import { ErrorBoundary } from "./pages/error/ErrorBoundary.tsx"; import ProjectCreateHome from "./pages/create_project/ProjectCreateHome.tsx"; +import SubmissionsOverview from "./pages/submission_overview/SubmissionsOverview.tsx"; import {fetchProjectPage} from "./pages/project/FetchProjects.tsx"; import HomePages from "./pages/home/HomePages.tsx"; @@ -13,8 +14,10 @@ const router = createBrowserRouter( <Route index element={<HomePages />} loader={fetchProjectPage}/> <Route path=":lang" element={<LanguagePath/>}> <Route path="home" element={<HomePages />} loader={fetchProjectPage} /> - <Route path="project" > - <Route path=":projectId" element={<ProjectView />}/> + <Route path="project/:projectId/overview" element={<SubmissionsOverview/>}/> + <Route path="project"> + <Route path=":projectId" element={<ProjectView />}> + </Route> </Route> <Route path="projects"> <Route path="create" element={<ProjectCreateHome />} /> diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index 7559368b..3b23815a 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -42,7 +42,7 @@ interface RegexData { regex: string; } -const apiUrl = import.meta.env.VITE_APP_API_URL +const apiUrl = import.meta.env.VITE_API_HOST const user = "Gunnar" /** diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx new file mode 100644 index 00000000..eae8b416 --- /dev/null +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx @@ -0,0 +1,64 @@ +import {Box, Button, Typography} from "@mui/material"; +import {useEffect, useState} from "react"; +import {useParams} from "react-router-dom"; +import ProjectSubmissionsOverviewDatagrid from "./ProjectSubmissionOverviewDatagrid.tsx"; +import download from 'downloadjs'; +import {useTranslation} from "react-i18next"; +const apiUrl = import.meta.env.VITE_API_HOST +const user = "teacher" + +/** + * @returns Overview page for submissions + */ +export default function ProjectSubmissionOverview() { + + const { t } = useTranslation('submissionOverview', { keyPrefix: 'submissionOverview' }); + + useEffect(() => { + fetchProject(); + }); + + const fetchProject = async () => { + const response = await fetch(`${apiUrl}/projects/${projectId}`, { + headers: { + "Authorization": user + }, + }) + const jsonData = await response.json(); + setProjectTitle(jsonData["data"].title); + + } + + const downloadProjectSubmissions = async () => { + await fetch(`${apiUrl}/projects/${projectId}/submissions-download`, { + headers: { + "Authorization": user + }, + }) + .then(res => { + return res.blob(); + }) + .then(blob => { + download(blob, 'submissions.zip'); + }); + } + + const [projectTitle, setProjectTitle] = useState<string>("") + const { projectId } = useParams<{ projectId: string }>(); + + return ( + <Box + display="flex" + flexDirection="column" + alignItems="center" + justifyContent="center" + paddingTop="50px" + > + <Box width="40%"> + <Typography minWidth="440px" variant="h6" align="left">{projectTitle}</Typography> + <ProjectSubmissionsOverviewDatagrid /> + </Box> + <Button onClick={downloadProjectSubmissions} variant="contained">{t("downloadButton")}</Button> + </Box> + ) +} \ No newline at end of file diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx new file mode 100644 index 00000000..a3d02d8f --- /dev/null +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx @@ -0,0 +1,111 @@ +import {useParams} from "react-router-dom"; +import {useEffect, useState} from "react"; +import {DataGrid, GridColDef, GridRenderCellParams} from "@mui/x-data-grid"; +import {Box, IconButton} from "@mui/material"; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { green, red } from '@mui/material/colors'; +import CancelIcon from '@mui/icons-material/Cancel'; +import DownloadIcon from '@mui/icons-material/Download'; +import download from "downloadjs"; + +const apiUrl = import.meta.env.VITE_API_HOST +const user = "teacher" + + interface Submission { + grading: string; + project_id: string; + submission_id: string; + submission_path: string; + submission_status: string; + submission_time: string; + uid: string; + } + +/** + * @returns unique id for datarows + */ +function getRowId(row: Submission) { + return row.submission_id; +} + +const fetchSubmissionsFromUser = async (submission_id: string) => { + await fetch(`${apiUrl}/submissions/${submission_id}/download`, { + headers: { + "Authorization": user + }, + }) + .then(res => { + return res.blob(); + }) + .then(blob => { + download(blob, `submissions_${submission_id}.zip`); + }); +} + +const columns: GridColDef<Submission>[] = [ + { field: 'submission_id', headerName: 'Submission ID', flex: 0.4 }, + { field: 'uid', headerName: 'Student ID', width: 160, flex: 0.4 }, + { + field: 'grading', + headerName: 'Grading', + editable: true, + flex: 0.2 + }, + { + field: 'submission_status', + headerName: 'Status', + renderCell: (params: GridRenderCellParams<Submission>) => ( + <> + { + params.row.submission_status === "SUCCESS" ? ( + <CheckCircleIcon sx={{ color: green[500] }} /> + ) : <CancelIcon sx={{ color: red[500] }}/> + } + </> + ) + }, + { + field: 'submission_path', + headerName: 'Download', + renderCell: (params: GridRenderCellParams<Submission>) => ( + <IconButton onClick={() => fetchSubmissionsFromUser(params.row.submission_id)}> + <DownloadIcon /> + </IconButton> + ) + }]; + +/** + * @returns the datagrid for displaying submissiosn + */ +export default function ProjectSubmissionsOverviewDatagrid() { + const { projectId } = useParams<{ projectId: string }>(); + const [submissions, setSubmissions] = useState<Submission[]>([]) + + useEffect(() => { + fetchLastSubmissionsByUser(); + }); + + const fetchLastSubmissionsByUser = async () => { + const response = await fetch(`${apiUrl}/projects/${projectId}/latest-per-user`, { + headers: { + "Authorization": user + }, + }) + const jsonData = await response.json(); + setSubmissions(jsonData.data); + } + + return ( + <Box + my={4} + > + <DataGrid + getRowId={getRowId} + rows={submissions} + columns={columns} + pageSizeOptions={[20]} + disableRowSelectionOnClick + /> + </Box> + ) +} \ No newline at end of file diff --git a/frontend/src/pages/submission_overview/SubmissionsOverview.tsx b/frontend/src/pages/submission_overview/SubmissionsOverview.tsx new file mode 100644 index 00000000..7318891d --- /dev/null +++ b/frontend/src/pages/submission_overview/SubmissionsOverview.tsx @@ -0,0 +1,17 @@ +import {useTranslation} from "react-i18next"; +import { Title } from "../../components/Header/Title.tsx"; +import ProjectSubmissionOverview from "../../components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx"; + +/** + * + * @returns Wrapper for page of submissions overview + */ +export default function SubmissionsOverview() { + + const { t } = useTranslation('submissionOverview', { keyPrefix: 'submissionOverview' }); + + return (<> + <Title title={t("submissionOverviewHeader")}/> + <ProjectSubmissionOverview/> + </>) +} \ No newline at end of file From 9b61937f6fe6ab008a4901fc0651464c66429561 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Thu, 18 Apr 2024 21:58:35 +0200 Subject: [PATCH 285/377] Project overview page (#222) * start homepage * added calender functions and titlecard * homepage changes * homepage changes * Added: Homepage and Student Homepage * homepage changes * project projects parser * test passed * linter * pr review * Project card refactor * homepage change fix * Revert "Merge remote-tracking branch 'origin/backend/projectendpoint-fix' into frontend/feature/homepage" This reverts commit 645443a0aca84b31465aee99e59eda91cf8dadc4, reversing changes made to e5451b6cdd166f1c4632edddff9b4d9385f137cd. * homepage changes * rm comment * pr changes * project overview * project overview * project overview done * pr changes * project page changes * added support for no deadline projects * link changed * link changed * pr changes * merged with HomeStudent.tsx * projects overview page * wording change * homepage change * homepage change * API URL to API_HOST * end point with /me * project pages * home * create button * create button * opt * deadline fix * deadline fix * deadline fix * deadline fix * creds --------- Co-authored-by: gerwoud <gerwoud@hotmail.be> --- frontend/public/locales/en/translation.json | 6 ++ frontend/public/locales/nl/translation.json | 6 ++ frontend/src/App.tsx | 2 + frontend/src/pages/project/FetchProjects.tsx | 22 ++--- .../projectDeadline/ProjectDeadline.tsx | 3 +- .../projectDeadline/ProjectDeadlineCard.tsx | 48 +++++---- .../src/pages/project/projectOverview.tsx | 97 +++++++++++++++++++ 7 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 frontend/src/pages/project/projectOverview.tsx diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 27bcab24..1a438508 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -95,5 +95,11 @@ "no_submission_yet" : "No submission yet", "loading": "Loading...", "no_projects": "There are no projects here." + }, + "projectsOverview": { + "past_deadline": "Past Projects", + "future_deadline": "Upcoming Deadlines", + "no_projects": "There are no projects here.", + "new_project": "New Project" } } diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index bdd4028f..cbccc0dc 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -76,6 +76,12 @@ "minutesAgo": "minuten geleden", "justNow": "Zonet" }, + "projectsOverview": { + "past_deadline": "Verlopen Projecten", + "future_deadline": "Opkomende Deadlines", + "no_projects": "Er zijn hier geen projecten.", + "new_project": "Nieuw Project" + }, "error": { "pageNotFound": "Pagina Niet Gevonden", "pageNotFoundMessage": "De opgevraagde pagina werd niet gevonden.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3e9a296..8f4f2e28 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import ProjectCreateHome from "./pages/create_project/ProjectCreateHome.tsx"; import SubmissionsOverview from "./pages/submission_overview/SubmissionsOverview.tsx"; import {fetchProjectPage} from "./pages/project/FetchProjects.tsx"; import HomePages from "./pages/home/HomePages.tsx"; +import ProjectOverView from "./pages/project/projectOverview.tsx"; const router = createBrowserRouter( createRoutesFromElements( @@ -20,6 +21,7 @@ const router = createBrowserRouter( </Route> </Route> <Route path="projects"> + <Route index element={<ProjectOverView/>} loader={fetchProjectPage}/> <Route path="create" element={<ProjectCreateHome />} /> </Route> </Route> diff --git a/frontend/src/pages/project/FetchProjects.tsx b/frontend/src/pages/project/FetchProjects.tsx index 1b988825..81444303 100644 --- a/frontend/src/pages/project/FetchProjects.tsx +++ b/frontend/src/pages/project/FetchProjects.tsx @@ -1,8 +1,6 @@ import {Project, ProjectDeadline, ShortSubmission} from "./projectDeadline/ProjectDeadline.tsx"; const API_URL = import.meta.env.VITE_APP_API_HOST -const header = { - "Authorization": "teacher2" -} + export const fetchProjectPage = async () => { const projects = await fetchProjects() const me = await fetchMe() @@ -12,7 +10,7 @@ export const fetchProjectPage = async () => { export const fetchMe = async () => { try { const response = await fetch(`${API_URL}/me`, { - headers:header + credentials: 'include' }) if(response.status == 200){ const data = await response.json() @@ -29,7 +27,8 @@ export const fetchProjects = async () => { try{ const response = await fetch(`${API_URL}/projects`, { - headers:header + credentials: 'include' + }) const jsonData = await response.json(); let formattedData: ProjectDeadline[] = await Promise.all( jsonData.data.map(async (item:Project) => { @@ -37,24 +36,25 @@ export const fetchProjects = async () => { const url_split = item.project_id.split('/') const project_id = url_split[url_split.length -1] const response_submissions = await (await fetch(encodeURI(`${API_URL}/submissions?project_id=${project_id}`), { - headers: header + credentials: 'include' + })).json() //get the latest submission const latest_submission = response_submissions.data.map((submission:ShortSubmission) => ({ submission_id: submission.submission_id,//this is the path submission_time: new Date(submission.submission_time), - submission_status: submission.submission_status + submission_status: submission.submission_status, + grading: submission.grading } )).sort((a:ShortSubmission, b:ShortSubmission) => b.submission_time.getTime() - a.submission_time.getTime())[0]; - // fetch the course id of the project - const project_item = await (await fetch(encodeURI(`${API_URL}/projects/${project_id}`), { - headers:header + // fetch the course id of the project + const project_item = await (await fetch(encodeURI(`${API_URL}/projects/${project_id}`), { credentials: 'include' })).json() //fetch the course const response_courses = await (await fetch(encodeURI(`${API_URL}/courses/${project_item.data.course_id}`), { - headers: header + credentials: 'include' })).json() const course = { course_id: response_courses.data.course_id, diff --git a/frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx b/frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx index 50f16a60..d16f3e3e 100644 --- a/frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx +++ b/frontend/src/pages/project/projectDeadline/ProjectDeadline.tsx @@ -45,5 +45,6 @@ export interface Course { export interface ShortSubmission { submission_id:number, submission_time:Date, - submission_status:string + submission_status:string, + grading: number } diff --git a/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx index 56d2351d..b3ed7413 100644 --- a/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx +++ b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx @@ -2,13 +2,13 @@ import {CardActionArea, Card, CardContent, Typography, Box, Button} from '@mui/m import {Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import dayjs from "dayjs"; -import {ProjectDeadline, Deadline} from "./ProjectDeadline.tsx"; +import {ProjectDeadline} from "./ProjectDeadline.tsx"; import React from "react"; import { useNavigate } from 'react-router-dom'; interface ProjectCardProps{ deadlines:ProjectDeadline[], - pred?: (deadline:Deadline) => boolean + showCourse?:boolean } /** @@ -17,7 +17,7 @@ interface ProjectCardProps{ * @param pred - A predicate to filter the deadlines * @returns Element */ -export const ProjectDeadlineCard: React.FC<ProjectCardProps> = ({ deadlines }) => { +export const ProjectDeadlineCard: React.FC<ProjectCardProps> = ({ deadlines, showCourse = true }) => { const { t } = useTranslation('translation', { keyPrefix: 'student' }); const { i18n } = useTranslation(); const navigate = useNavigate(); @@ -34,23 +34,25 @@ export const ProjectDeadlineCard: React.FC<ProjectCardProps> = ({ deadlines }) (project.short_submission.submission_status === 'SUCCESS' ? 'green' : 'red') : '#686868'}}> {project.title} </Typography> - <Typography variant="subtitle1"> - {t('course')}: - <Button - style={{ - color: 'inherit', - textTransform: 'none' - }} - onMouseDown={event => event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); // stops the event from reaching CardActionArea - event.preventDefault(); - navigate(`/${i18n.language}/courses/${project.course.course_id}`) - }} - > - {project.course.name} - </Button> - </Typography> + {showCourse && ( + <Typography variant="subtitle1"> + {t('course')}: + <Button + style={{ + color: 'inherit', + textTransform: 'none' + }} + onMouseDown={event => event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); // stops the event from reaching CardActionArea + event.preventDefault(); + navigate(`/${i18n.language}/courses/${project.course.course_id}`) + }} + > + {project.course.name} + </Button> + </Typography> + )} <Typography variant="body2" color="textSecondary"> {t('last_submission')}: {project.short_submission ? t(project.short_submission.submission_status.toString()) : t('no_submission_yet')} @@ -60,7 +62,11 @@ export const ProjectDeadlineCard: React.FC<ProjectCardProps> = ({ deadlines }) Deadline: {dayjs(project.deadline).format('MMMM D, YYYY')} </Typography> )} - + {project.short_submission?.grading && ( + <Typography variant="body2" align="right"> + {project.short_submission.grading}/20 + </Typography> + )} </CardContent> </CardActionArea> </Card> diff --git a/frontend/src/pages/project/projectOverview.tsx b/frontend/src/pages/project/projectOverview.tsx new file mode 100644 index 00000000..9de97da1 --- /dev/null +++ b/frontend/src/pages/project/projectOverview.tsx @@ -0,0 +1,97 @@ +import {ProjectDeadline} from "./projectDeadline/ProjectDeadline.tsx"; +import {Button, Card, CardContent, Container, Grid, Typography, Link} from "@mui/material"; +import {ProjectDeadlineCard} from "./projectDeadline/ProjectDeadlineCard.tsx"; +import { useTranslation } from "react-i18next"; +import {Title} from "../../components/Header/Title.tsx"; +import {useLoaderData, Link as RouterLink} from "react-router-dom"; +import dayjs from "dayjs"; + +/** + * Displays all the projects + * @returns the project page + */ +export default function ProjectOverView() { + const {i18n} = useTranslation() + const { t } = useTranslation('translation', { keyPrefix: 'projectsOverview' }); + const loader = useLoaderData() as { + projects: ProjectDeadline[], + me: string + } + const projects = loader.projects + const me = loader.me + + const projectReducer = (acc: {[key: string]: ProjectDeadline[]}, project: ProjectDeadline) => { + (acc[project.course.course_id] = acc[project.course.course_id] || []).push(project); + return acc; + } + const futureProjectsByCourse = projects + .filter((p) => (p.deadline && dayjs(dayjs()).isBefore(p.deadline))) + .sort((a, b) => dayjs(a.deadline).diff(dayjs(b.deadline))) + .reduce(projectReducer, {}); + const pastProjectsByCourse = projects + .filter((p) => p.deadline && (dayjs()).isAfter(p.deadline)) + .sort((a, b) => dayjs(b.deadline).diff(dayjs(a.deadline))) + .reduce(projectReducer, {}); + const noDeadlineProject = projects.filter((p) => p.deadline === undefined) + .reduce(projectReducer,{}); + + const projectItem = ([index, courseProjects] : [string, ProjectDeadline[]]) =>{ + return ( + <Grid container spacing={2} key={index}> + <Grid item xs={12}> + <Link href={`/${i18n.language}/course/${courseProjects[0].course.course_id}`} style={{color: 'inherit'}} + underline={'none'}> + <Typography variant="h6">{courseProjects[0].course.name} {courseProjects[0].course.ufora_id}</Typography> + </Link> + </Grid> + <Grid item xs={8}> + <ProjectDeadlineCard deadlines={courseProjects} showCourse={false} /> + </Grid> + </Grid> + ) + } + return ( + <Container style={{ paddingTop: '50px' }}> + <Title title={"Projects Overview"}/> + <Grid container spacing={2}> + <Grid item xs={2}> + {me === 'TEACHER' && ( + <Button component={RouterLink} to={`/${i18n.language}/projects/create`}>{t('new_project')}</Button> + )} + </Grid> + <Grid item xs={5}> + <Card> + <CardContent> + <Typography variant="h5" style={{ color: '#3f51b5' }}>{t("future_deadline")}:</Typography> + {Object.keys(futureProjectsByCourse).length + Object.keys(noDeadlineProject).length === 0 ? ( + <Typography variant="body1"> + {t('no_projects')} + </Typography> + ) :( + [...Object.entries(futureProjectsByCourse), + ...Object.entries(noDeadlineProject)].map(projectItem) + )} + </CardContent> + </Card> + + </Grid> + <Grid item xs={5}> + <Card> + <CardContent> + <Typography variant="h5" style={{ color: '#3f51b5' }}>{t("past_deadline")}:</Typography> + { + Object.keys(pastProjectsByCourse).length === 0 ? ( + <Typography variant="body1"> + {t('no_projects')} + </Typography> + ):( + Object.entries(pastProjectsByCourse).map(projectItem) + ) + } + </CardContent> + </Card> + </Grid> + </Grid> + </Container> + ) +} \ No newline at end of file From 89d357a375ba43401ffa0adc4d880f932653c07a Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Fri, 19 Apr 2024 14:09:18 +0200 Subject: [PATCH 286/377] Course views for teacher (#161) * teacher detailed course base project list * basic * scrollers * doc * backtick strings * base all courses page * changed height * first finished version of teacher course views * teacher all course page reviewed * display time until deadline * more searchbars and translations * changed file structure for maintainability * titles and absolute create button * searchbars update url and remain on reload * user and admin listing * reworked form and gave unique ids to all list items * whoops linter ssst * added .env to gitignore * placeholder for loading courses * debounce seachbars * better debouncing more hooks * setup for loader but doesnt work yet bc bad router * loaders * course detail teacher course join codes menu * join code link points to app host now * accident * fixed empty filters, placeholders and teacher in admin list * delete * removed wannabe popup errorer * unuserud * package * fix * uhh * u --- .../project/endpoints/authentication/auth.py | 5 - .../project/endpoints/authentication/me.py | 2 +- .../courses/join_codes/course_join_codes.py | 2 +- backend/project/models/user.py | 4 +- backend/project/utils/models/user_utils.py | 2 +- backend/project/utils/query_agent.py | 2 +- frontend/.gitignore | 2 + frontend/package-lock.json | 22 +- frontend/package.json | 9 +- frontend/public/locales/en/translation.json | 44 +- frontend/public/locales/nl/translation.json | 44 +- frontend/src/App.tsx | 7 + .../components/Courses/AllCoursesTeacher.tsx | 79 ++++ .../Courses/CourseDetailTeacher.tsx | 411 ++++++++++++++++++ .../Courses/CourseUtilComponents.tsx | 220 ++++++++++ .../src/components/Courses/CourseUtils.tsx | 146 +++++++ .../components/ProjectForm/ProjectForm.tsx | 8 +- .../ProjectSubmissionOverview.tsx | 9 +- .../ProjectSubmissionOverviewDatagrid.tsx | 9 +- .../pages/project/projectView/ProjectView.tsx | 6 +- .../project/projectView/SubmissionCard.tsx | 2 +- .../project/projectView/SubmissionsGrid.tsx | 2 +- frontend/src/utils/date-utils.ts | 28 +- 23 files changed, 1011 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/Courses/AllCoursesTeacher.tsx create mode 100644 frontend/src/components/Courses/CourseDetailTeacher.tsx create mode 100644 frontend/src/components/Courses/CourseUtilComponents.tsx create mode 100644 frontend/src/components/Courses/CourseUtils.tsx diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py index ab59ef5b..86bf96dc 100644 --- a/backend/project/endpoints/authentication/auth.py +++ b/backend/project/endpoints/authentication/auth.py @@ -45,11 +45,6 @@ def microsoft_authentication(): res = requests.post(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token", data=data, timeout=5) - if res.status_code != 200: - abort(make_response(( - {"message": - "An error occured while trying to authenticate your authorization code"}, - 500))) token = res.json()["access_token"] profile_res = requests.get("https://graph.microsoft.com/v1.0/me", headers={"Authorization":f"Bearer {token}"}, diff --git a/backend/project/endpoints/authentication/me.py b/backend/project/endpoints/authentication/me.py index c4f19a70..bd43d8aa 100644 --- a/backend/project/endpoints/authentication/me.py +++ b/backend/project/endpoints/authentication/me.py @@ -23,7 +23,7 @@ def get(self): """ Will return all user data associated with the access token in the request """ - uid = get_jwt_identity + uid = get_jwt_identity() return query_by_id_from_model(User, "uid", diff --git a/backend/project/endpoints/courses/join_codes/course_join_codes.py b/backend/project/endpoints/courses/join_codes/course_join_codes.py index a2401783..1eb7e00c 100644 --- a/backend/project/endpoints/courses/join_codes/course_join_codes.py +++ b/backend/project/endpoints/courses/join_codes/course_join_codes.py @@ -34,7 +34,7 @@ def get(self, course_id): return query_selected_from_model( CourseShareCode, urljoin(f"{RESPONSE_URL}/", f"{str(course_id)}/", "join_codes"), - select_values=["join_code", "expiry_time"], + select_values=["join_code", "expiry_time", "for_admins"], filters={"course_id": course_id} ) diff --git a/backend/project/models/user.py b/backend/project/models/user.py index 7bc9ed30..4f03874e 100644 --- a/backend/project/models/user.py +++ b/backend/project/models/user.py @@ -30,6 +30,6 @@ def to_dict(self): """ return { 'uid': self.uid, - 'role': self.role.name, # Convert the enum to a string - 'display_name': self.display_name + 'display_name': self.display_name, + 'role': self.role.name # Convert the enum to a string } diff --git a/backend/project/utils/models/user_utils.py b/backend/project/utils/models/user_utils.py index 37cd263c..21568c6d 100644 --- a/backend/project/utils/models/user_utils.py +++ b/backend/project/utils/models/user_utils.py @@ -21,7 +21,7 @@ def get_user(user_id): db.session.rollback() abort(make_response(({"message": "An error occurred while fetching the user"} , 500))) - if not user: + if user is None: abort(make_response(({"message":f"User with id: {user_id} not found"}, 404))) return user diff --git a/backend/project/utils/query_agent.py b/backend/project/utils/query_agent.py index 96e25045..94ace471 100644 --- a/backend/project/utils/query_agent.py +++ b/backend/project/utils/query_agent.py @@ -193,7 +193,7 @@ def query_by_id_from_model(model: DeclarativeMeta, if not result: return {"message": "Resource not found", "url": base_url}, 404 return { - "data": result, + "data": result.to_dict() if hasattr(result, "to_dict") else result, "message": "Resource fetched correctly", "url": urljoin(f"{base_url}/", str(column_id))}, 200 except SQLAlchemyError: diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36..d7de12f3 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,8 @@ dist dist-ssr *.local +.env + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1674097e..8fc1e44f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,16 +8,17 @@ "name": "ugent-3", "version": "0.0.0", "dependencies": { - "@emotion/react": "^11.11.3", - "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.15.10", + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.15.15", "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.10", + "@mui/material": "^5.15.15", "@mui/styled-engine-sc": "^6.0.0-alpha.16", "@mui/x-data-grid": "^7.1.1", "@mui/x-date-pickers": "^7.1.1", "axios": "^1.6.8", "dayjs": "^1.11.10", + "debounce": "^2.0.0", "downloadjs": "^1.4.7", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", @@ -31,8 +32,8 @@ "styled-components": "^6.1.8" }, "devDependencies": { - "@types/history": "^4.7.11", "@types/downloadjs": "^1.4.6", + "@types/history": "^4.7.11", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@types/react-router-dom": "^5.3.3", @@ -3478,6 +3479,17 @@ "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", "integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==" }, + "node_modules/debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.0.0.tgz", + "integrity": "sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index f866c3cf..b1903b29 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,16 +12,17 @@ "test": "npm run cypress:test" }, "dependencies": { - "@emotion/react": "^11.11.3", - "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.15.10", + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.15.15", + "@mui/material": "^5.15.15", "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.10", "@mui/styled-engine-sc": "^6.0.0-alpha.16", "@mui/x-data-grid": "^7.1.1", "@mui/x-date-pickers": "^7.1.1", "axios": "^1.6.8", "dayjs": "^1.11.10", + "debounce": "^2.0.0", "downloadjs": "^1.4.7", "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 1a438508..212bd4cc 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -16,6 +16,43 @@ "welcomeDescription": "Welcome to Peristerónas, the online submission platform of UGent", "login": "Login" }, + "courseDetailTeacher": { + "title": "Course Details", + "deleteCourse": "Delete Course", + "unauthorizedDelete": "You are unauthorized to delete this course", + "noCoursesFound": "No courses found", + "noProjects": "No projects", + "noStudents": "No students in this course", + "joinCodes": "Join Codes", + "forAdmins": "For Admins", + "forStudents": "For Students", + "noExpiryDate": "No expiry date", + "expiryDate": "Expiry Date", + "newJoinCode": "New Join Code", + "deleteSelected": "Delete Selected Students", + "projects": "Projects", + "newProject": "New Project", + "assistantList": "List of co-teachers/assistants", + "newTeacher": "New teacher", + "studentList": "List of students", + "newStudent": "New student(s)", + "deadline": "Deadline", + "teacher": "Teacher", + "view": "View", + "admins": "Admins", + "students": "Students" + }, + "allCoursesTeacher": { + "title": "All Courses", + "courseForm": "Course Form", + "courseName": "Course Name", + "submit": "Submit", + "emptyCourseNameError": "Course name should not be empty", + "cancel": "Cancel", + "create": "Create", + "activeCourses": "Active Courses", + "archivedCourses":"Archived Courses" + }, "courseForm": { "courseName": "Course Name", "submit": "Submit", @@ -42,7 +79,12 @@ "daysAgo": "days ago", "hoursAgo": "hours ago", "minutesAgo": "minutes ago", - "justNow": "just now" + "justNow": "just now", + "yearsLater": "years later", + "monthsLater": "months later", + "daysLater": "days later", + "hoursLater": "hours later", + "minutesLater": "minutes later" }, "error": { "pageNotFound": "Page Not Found", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index cbccc0dc..7addc7e6 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -15,6 +15,43 @@ "welcomeDescription": "Welkom bij Peristerónas, het online indieningsplatform van UGent", "login": "Aanmelden" }, + "courseDetailTeacher": { + "title": "Vak Details", + "noCoursesFound": "Geen vakken gevonden", + "unauthorizedDelete": "U heeft niet de juiste rechten om dit vak te verwijderen", + "noProjects": "Geen projecten", + "noStudents": "Geen studenten voor dit vak", + "deleteCourse": "Verwijder vak", + "joinCodes": "Join Codes", + "forAdmins": "Voor Admins", + "forStudents": "Voor Studenten", + "noExpiryDate": "Geen vervaldatum", + "expiryDate": "Vervaldatum", + "newJoinCode": "Nieuwe Join Code", + "deleteSelected": "Verwijder geselecteerde studenten", + "projects": "Projecten", + "newProject": "Nieuw Project", + "assistantList": "Lijst co-leerkrachten/assistenten", + "newTeacher": "Nieuwe leerkracht", + "studentList": "Lijst studenten", + "newStudent": "Nieuwe student(en)", + "deadline": "Deadline", + "teacher": "Leerkracht", + "view": "Bekijk", + "admins": "Admins", + "students": "Studenten" + }, + "allCoursesTeacher": { + "title": "Alle Vakken", + "courseForm": "Vak Form", + "courseName": "Vak Naam", + "submit": "Opslaan", + "emptyCourseNameError": "Vak naam mag niet leeg zijn", + "cancel": "Annuleer", + "create": "Nieuw Vak", + "activeCourses": "Actieve Vakken", + "archivedCourses":"Gearchiveerde Vakken" + }, "courseForm": { "courseName": "Vak Naam", "submit": "Opslaan", @@ -74,7 +111,12 @@ "daysAgo": "dagen geleden", "hoursAgo": "uur geleden", "minutesAgo": "minuten geleden", - "justNow": "Zonet" + "justNow": "Zonet", + "yearsLater": "jaren later", + "monthsLater": "maanden later", + "daysLater": "dagen later", + "hoursLater": "uur later", + "minutesLater": "minuten later" }, "projectsOverview": { "past_deadline": "Verlopen Projecten", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8f4f2e28..efe17395 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,8 @@ import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; import Layout from "./components/Header/Layout"; +import { AllCoursesTeacher } from "./components/Courses/AllCoursesTeacher"; +import { CourseDetailTeacher } from "./components/Courses/CourseDetailTeacher"; +import { dataLoaderCourseDetail, dataLoaderCourses } from "./components/Courses/CourseUtils"; import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; import { ErrorBoundary } from "./pages/error/ErrorBoundary.tsx"; @@ -20,6 +23,10 @@ const router = createBrowserRouter( <Route path=":projectId" element={<ProjectView />}> </Route> </Route> + <Route path="courses"> + <Route index element={<AllCoursesTeacher />} loader={dataLoaderCourses}/> + <Route path=":courseId" element={<CourseDetailTeacher />} loader={dataLoaderCourseDetail} /> + </Route> <Route path="projects"> <Route index element={<ProjectOverView/>} loader={fetchProjectPage}/> <Route path="create" element={<ProjectCreateHome />} /> diff --git a/frontend/src/components/Courses/AllCoursesTeacher.tsx b/frontend/src/components/Courses/AllCoursesTeacher.tsx new file mode 100644 index 00000000..facfa9c2 --- /dev/null +++ b/frontend/src/components/Courses/AllCoursesTeacher.tsx @@ -0,0 +1,79 @@ +import { Button, Dialog, DialogActions, DialogTitle, FormControl, FormHelperText, Grid, Input, InputLabel } from "@mui/material"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { SideScrollableCourses } from "./CourseUtilComponents"; +import { Course, callToApiToCreateCourse } from "./CourseUtils"; +import { Title } from "../Header/Title"; +import { useLoaderData } from "react-router-dom"; + +/** + * @returns A jsx component representing all courses for a teacher + */ +export function AllCoursesTeacher(): JSX.Element { + const [open, setOpen] = useState(false); + const courses = (useLoaderData() as Course[]); + + const [courseName, setCourseName] = useState(''); + const [error, setError] = useState(''); + + const navigate = useNavigate(); + + const { t } = useTranslation('translation', { keyPrefix: 'allCoursesTeacher' }); + + const handleClickOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { + setCourseName(event.target.value); + setError(''); // Clearing error message when user starts typing + }; + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); // Prevents the default form submission behaviour + + if (!courseName.trim()) { + setError(t('emptyCourseNameError')); + return; + } + + const data = { name: courseName }; + callToApiToCreateCourse(JSON.stringify(data), navigate); + }; + return ( + <> + <Title title={t('title')}> + + + + {t('courseForm')} +
+ + {t('courseName')} + + {error && {error}} + + + + + +
+
+ + + +
+ + ); +} \ No newline at end of file diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx new file mode 100644 index 00000000..e1cf0ff3 --- /dev/null +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -0,0 +1,411 @@ +import { Box, Button, Card, CardActions, CardContent, CardHeader, Checkbox, FormControlLabel, Grid, IconButton, Input, Menu, MenuItem, Paper, Typography } from "@mui/material"; +import { ChangeEvent, useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost } from "./CourseUtils"; +import { Link, useNavigate, NavigateFunction, useLoaderData } from "react-router-dom"; +import { Title } from "../Header/Title"; +import ClearIcon from '@mui/icons-material/Clear'; +import { timeDifference } from "../../utils/date-utils"; + +interface UserUid{ + uid: string +} + +/** + * Handles the deletion of an admin. + * @param navigate - The navigate function from react-router-dom. + * @param courseId - The ID of the course. + * @param uid - The UID of the admin. + */ +function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: string): void { + fetch(`${apiHost}/courses/${courseId}/admins`, { + method: 'DELETE', + credentials: 'include', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "admin_uid": uid + }) + }) + .then(() => { + navigate(0); + }); +} + +/** + * Handles the deletion of a student. + * @param navigate - The navigate function from react-router-dom. + * @param courseId - The ID of the course. + * @param uid - The UID of the admin. + */ +function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: string[]): void { + fetch(`${apiHost}/courses/${courseId}/students`, { + method: 'DELETE', + credentials: 'include', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "students": uids + }) + }) + .then(() => { + navigate(0); + }); +} + +/** + * Handles the deletion of a course. + * @param navigate - The navigate function from react-router-dom. + * @param courseId - The ID of the course. + */ +function handleDeleteCourse(navigate: NavigateFunction, courseId: string): void { + fetch(`${apiHost}/courses/${courseId}`, { + method: 'DELETE', + credentials: 'include', + }).then((response) => { + if(response.ok){ + navigate(-1); + } + else if(response.status === 404){ + navigate(-1); + } + }); +} + +/** + * + * @returns A jsx component representing the course detail page for a teacher + */ +export function CourseDetailTeacher(): JSX.Element { + const [selectedStudents, setSelectedStudents] = useState([]); + const [anchorEl, setAnchorElStudent] = useState(null); + const openCodes = Boolean(anchorEl); + const handleClickCodes = (event: React.MouseEvent) => { + setAnchorElStudent(event.currentTarget); + }; + const handleCloseCodes = () => { + setAnchorElStudent(null); + }; + + const courseDetail = useLoaderData() as { //TODO CATCH ERROR + course: Course , + projects:Project[] , + admins: UserUid[], + students: UserUid[] + }; + const { course, projects, admins, students } = courseDetail; + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + const { i18n } = useTranslation(); + const lang = i18n.language; + const navigate = useNavigate(); + + const handleCheckboxChange = (event: ChangeEvent, uid: string) => { + if (event.target.checked) { + setSelectedStudents((prevSelected) => [...prevSelected, uid]); + } else { + setSelectedStudents((prevSelected) => + prevSelected.filter((student) => student !== uid) + ); + } + }; + + return ( + <> + + + + + {t('projects')}: + + + + + + + + + {t('admins')}: + + {admins.map((admin) => ( + + + {getUserName(admin.uid)} + + + + ))} + + + + + {t('students')}: + + + + handleDeleteStudent(navigate, course.course_id, selectedStudents)}> + + {t('deleteSelected')} + + + + + + + + + + + + + + ); +} + +/** + * @param projects - The array of projects. + * @returns Either a place holder for no projects or a grid of cards describing the projects. + */ +function EmptyOrNotProjects({projects}: {projects: Project[]}): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + if(projects === undefined || projects.length === 0){ + return ( + {t('noProjects')} + ); + } + else{ + return ( + + {projects?.map((project) => ( + + + + + + + {getNearestFutureDate(project.deadlines) && + ( + + {`${t('deadline')}: ${getNearestFutureDate(project.deadlines)?.toLocaleDateString()}`} + + )} + + + + + + + + + ))} + + ); + } +} + +/** + * @param navigate - The navigate function from react-router-dom. + * @param course - The course against which we will check if the uid is of the teacher. + * @param admin - The admin in question. + * @returns Either nothing, if the admin uid is of teacher or a delete button. + */ +function EitherDeleteIconOrNothing({admin, course, navigate} : {admin:UserUid, course:Course, navigate: NavigateFunction}) : JSX.Element{ + if(course.teacher === getIdFromLink(admin.uid)){ + return <>; + } + else{ + return ( + + handleDeleteAdmin(navigate,course.course_id,getIdFromLink(admin.uid))}> + + + + ); + } +} + +/** + * @param students - The array of students. + * @param selectedStudents - The array of selected students. + * @param handleCheckboxChange - The function to handle the checkbox change. + * @returns Either a place holder for no students or a grid of checkboxes for the students. + */ +function EmptyOrNotStudents({students, selectedStudents, handleCheckboxChange}: {students: UserUid[], selectedStudents: string[], handleCheckboxChange: (event: React.ChangeEvent, studentId: string) => void}): JSX.Element { + if(students.length === 0){ + return ( + No students found + ); + } + else{ + return ( + + {students.map((student) => ( + + + handleCheckboxChange(event, getIdFromLink(student.uid))} + /> + + + {getUserName(student.uid)} + + + ))} + + ); + } +} + +interface JoinCode{ + join_code: string, + expiry_time: string, + for_admins: boolean +} + +/** + * Renders the JoinCodeMenu component. + * @param open - Whether the dialog is open or not. + * @param handleClose - Function to handle the dialog close event. + * @param handleNewCode - Function to handle the creation of a new join code. + * @param handleDeleteCode - Function to handle the deletion of a join code. + * @param getCodes - Function to get the list of join codes. + * @returns The rendered JoinCodeDialog component. + */ +function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, open: boolean, handleClose : () => void, anchorEl: HTMLElement | null}) { + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + + const [codes, setCodes] = useState([]); + const [expiry_time, setExpiryTime] = useState(null); + const [for_admins, setForAdmins] = useState(false); + + const handleInputChange = (event: React.ChangeEvent) => { + setExpiryTime(new Date(event.target.value)); + }; + + const handleCopyToClipboard = (join_code: string) => { + navigator.clipboard.writeText(`${appHost}/join-course?code=${join_code}`) + }; + + const getCodes = useCallback(() => { + fetch(`${apiHost}/courses/${courseId}/join_codes`, { + method: 'GET', + credentials: 'include', + }) + .then(response => response.json()) + .then(data => { + const filteredData = data.data.filter((code: JoinCode) => { + // Filter out expired codes + let expired = false; + if(code.expiry_time !== null){ + const expiryTime = new Date(code.expiry_time); + const now = new Date(); + expired = expiryTime < now; + } + + return !expired; + }); + setCodes(filteredData); + }) + }, [courseId]); + + const handleNewCode = () => { + + const bodyContent: { for_admins: boolean, expiry_time?: string } = { "for_admins": for_admins }; + if (expiry_time !== null) { + bodyContent.expiry_time = expiry_time.toISOString(); + } + + fetch(`${apiHost}/courses/${courseId}/join_codes`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(bodyContent) + }) + .then(() => getCodes()) + } + + const handleDeleteCode = (joinCode: string) => { + fetch(`${apiHost}/courses/${courseId}/join_codes/${joinCode}`, + { + method: 'DELETE', + credentials: 'include', + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + "join_code": joinCode + }) + }) + .then(() => getCodes()); + } + + useEffect(() => { + getCodes(); + }, [t, getCodes ]); + + return ( + + + + {t('joinCodes')} + + + {codes.map((code:JoinCode) => ( + handleCopyToClipboard(code.join_code)} key={code.join_code}> + + + {code.expiry_time ? timeDifference(code.expiry_time) : t('noExpiryDate')} + + + {code.for_admins ? t('forAdmins') : t('forStudents')} + + + handleDeleteCode(code.join_code)}> + + + + + + ))} + + + + setForAdmins(event.target.checked)} + name="forAdmins" + color="primary" + /> + } + /> + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx new file mode 100644 index 00000000..6a9e4804 --- /dev/null +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -0,0 +1,220 @@ +import { Box, Button, Card, CardActions, CardContent, CardHeader, Grid, Paper, TextField, Typography } from "@mui/material"; +import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate } from "./CourseUtils"; +import { Link, useNavigate, useLocation } from "react-router-dom"; +import { useState, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import debounce from 'debounce'; + +/** + * @param text - The text to be displayed + * @returns Typography that overflow into ... when text is too long + */ +export function EpsilonTypography({text} : {text: string}): JSX.Element { + return ( + {text} + ); +} + +/** + * @param label - The label of the search box + * @param searchTerm - The current search term + * @param handleSearchChange - The function to handle search term changes + * @returns a Grid item containing a TextField, used for searching/filtering + */ +export function SearchBox({label,searchTerm,handleSearchChange}: {label: string, searchTerm: string, handleSearchChange: (event: React.ChangeEvent) => void}): JSX.Element { + return ( + + + + + + ); +} + +/** + * We should reuse this in the student course view since it will be mainly the same except the create button. + * @param props - The component props requiring the courses that will be displayed in the scroller. + * @returns A component to display courses in horizontal scroller where each course is a card containing its name. + */ +export function SideScrollableCourses({courses}: {courses: Course[]}): JSX.Element { + //const navigate = useNavigate(); + const location = useLocation(); + const navigate = useNavigate(); + + // Get initial state from URL + const urlParams = useMemo(() => new URLSearchParams(location.search), [location.search]); //useMemo so only recompute when location.search changes + const initialSearchTerm = urlParams.get('name') || ''; + const initialUforaIdFilter = urlParams.get('ufora_id') || ''; + const initialTeacherNameFilter = urlParams.get('teacher') || ''; + + const [searchTerm, setSearchTerm] = useState(initialSearchTerm); + const [uforaIdFilter, setUforaIdFilter] = useState(initialUforaIdFilter); + const [teacherNameFilter, setTeacherNameFilter] = useState(initialTeacherNameFilter); + const [projects, setProjects] = useState<{ [courseId: string]: Project[] }>({}); + + const debouncedHandleSearchChange = useMemo(() => + debounce((key: string, value: string) => { + if (value === '') { + urlParams.delete(key); + } else { + urlParams.set(key, value); + } + const newUrl = `${location.pathname}?${urlParams.toString()}`; + navigate(newUrl, { replace: true }); + }, 500), [urlParams, navigate, location.pathname]); + + useEffect(() => { + debouncedHandleSearchChange('name', searchTerm); + }, [searchTerm, debouncedHandleSearchChange]); + + useEffect(() => { + debouncedHandleSearchChange('ufora_id', uforaIdFilter); + }, [uforaIdFilter, debouncedHandleSearchChange]); + + useEffect(() => { + debouncedHandleSearchChange('teacher', teacherNameFilter); + }, [teacherNameFilter, debouncedHandleSearchChange]); + + const handleSearchChange = (event: React.ChangeEvent) => { + const newSearchTerm = event.target.value; + setSearchTerm(newSearchTerm); + }; + + const handleUforaIdFilterChange = (event: React.ChangeEvent) => { + const newUforaIdFilter = event.target.value; + setUforaIdFilter(newUforaIdFilter); + }; + + const handleTeacherNameFilterChange = (event: React.ChangeEvent) => { + const newTeacherNameFilter = event.target.value; + setTeacherNameFilter(newTeacherNameFilter); + }; + + useEffect(() => { + // Fetch projects for each course + const fetchProjects = async () => { + const projectPromises = courses.map(course => + fetch(`${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}`, + { credentials: 'include' } + ) + .then(response => response.json()) + ); + + const projectResults = await Promise.all(projectPromises); + const projectsMap: { [courseId: string]: Project[] } = {}; + + projectResults.forEach((result, index) => { + projectsMap[getIdFromLink(courses[index].course_id)] = result.data; + }); + + setProjects(projectsMap); + }; + + fetchProjects(); + }, [courses]); + + const filteredCourses = courses.filter(course => + course.name.toLowerCase().includes(searchTerm.toLowerCase()) && + (course.ufora_id ? course.ufora_id.toLowerCase().includes(uforaIdFilter.toLowerCase()) : !uforaIdFilter) && + course.teacher.toLowerCase().includes(teacherNameFilter.toLowerCase()) + ); + + return ( + + + + + + + + + ); +} + +/** + * Empty or not. + * @returns either a place holder or the actual content. + */ +function EmptyOrNotFilteredCourses({filteredCourses, projects}: {filteredCourses: Course[], projects: { [courseId: string]: Project[] }}): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + if(filteredCourses.length === 0){ + return ( + {t('noCoursesFound')} + ); + } + + return ( + + + {filteredCourses.map((course, index) => ( + + + }/> + + {course.ufora_id && ( + <> + Ufora_id: {course.ufora_id}
+ + )} + Teacher: {course.teacher} + + }/> + + {t('projects')}: + + + + + +
+
+ ))} +
+
+ ); +} +/** + * @param projects - The projects to be displayed if not empty + * @returns either a place holder with text for no projects or the projects + */ +function EmptyOrNotProjects({projects, noProjectsText}: {projects: Project[], noProjectsText:string}): JSX.Element { + if(projects === undefined || projects.length === 0){ + return ( + {noProjectsText} + ); + } + else{ + const now = new Date(); + return ( + <> + {projects.slice(0, 3).map((project) => { + let timeLeft = ''; + if (project.deadlines != undefined) { + const deadlineDate = getNearestFutureDate(project.deadlines); + if(deadlineDate == null){ + return <> + } + const diffTime = Math.abs(deadlineDate.getTime() - now.getTime()); + const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); + const diffDays = Math.ceil(diffHours * 24); + + timeLeft = diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; + } + return ( + + + + + + ); + })} + + ); + } +} \ No newline at end of file diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx new file mode 100644 index 00000000..51a462a2 --- /dev/null +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -0,0 +1,146 @@ +import { NavigateFunction, Params } from 'react-router-dom'; + +export interface Course{ + course_id: string, + name: string, + teacher:string, + ufora_id:string, + url:string +} + +export interface Project{ + title: string, + project_id: string, + deadlines: string[][] +} + +export const apiHost = import.meta.env.VITE_APP_API_HOST; +export const appHost = import.meta.env.VITE_APP_HOST; +/** + * @returns The uid of the acces token of the logged in user + */ +export function loggedInToken(){ + return "teacher1"; +} + +/** + * Get the username based on the provided uid. + * @param uid - The uid of the user. + * @returns The username. + */ +export function getUserName(uid: string): string { + return getIdFromLink(uid); +} + +/** + * @returns The Uid of the logged in user + */ +export function loggedInUid(){ + return "Gunnar"; +} + +/** + * On a succesfull post the function will redirect to the data.url of the response, this should point to the detail page + * @param data - course data to send to the api + * @param navigate - function that allows the app to redirect + */ +export function callToApiToCreateCourse(data: string, navigate: NavigateFunction){ + fetch(`${apiHost}/courses`, { + credentials: 'include', // include, *same-origin, omit + headers: { + "Content-Type": "application/json" + }, + method: 'POST', + body: data, + }) + .then(response => response.json()) + .then(data => { + //But first also make sure that teacher is in the course admins list + fetch(`${apiHost}/courses/${getIdFromLink(data.url)}/admins`, { + credentials: 'include', + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({admin_uid: loggedInUid()}) + }); + navigate(getIdFromLink(data.url)); // navigate to data.url + }) +} + +/** + * @param link - the link to the api endpoint + * @returns the Id at the end of the link + */ +export function getIdFromLink(link: string): string { + const parts = link.split('/'); + return parts[parts.length - 1]; +} + +/** + * Function to find the nearest future date from a list of dates + * @param dates - Array of dates + * @returns The nearest future date + */ +export function getNearestFutureDate(dates: string[][]): Date | null { + const now = new Date(); + const futureDates = dates.map(date => new Date(date[1])).filter(date => date > now); + if (futureDates.length === 0) return null; + return futureDates.reduce((nearest, current) => current < nearest ? current : nearest); +} + +/** + * Load courses for courses teacher page, this filters courses on logged in teacher uid + * @returns A Promise that resolves to the loaded courses data. + */ + +const fetchData = async (url: string, params?: URLSearchParams) => { + let uri = `${apiHost}/${url}`; + if(params){ + uri += `?${params}` + } + const res = await fetch(uri, { + credentials: 'include' + }); + if(res.status !== 200){ + throw new Response("Failed to fetch data", {status: res.status}); + } + const jsonResult = await res.json(); + + return jsonResult.data; +}; + +export const dataLoaderCourses = async () => { + //const params = new URLSearchParams({ 'teacher': loggedInUid() }); + return fetchData(`courses`); +}; + +const dataLoaderCourse = async (courseId: string) => { + return fetchData(`courses/${courseId}`); +}; + +const dataLoaderProjects = async (courseId: string) => { + const params = new URLSearchParams({ course_id: courseId }); + return fetchData(`projects`, params); +}; + +const dataLoaderAdmins = async (courseId: string) => { + return fetchData(`courses/${courseId}/admins`); +}; + +const dataLoaderStudents = async (courseId: string) => { + return fetchData(`courses/${courseId}/students`); +}; + +export const dataLoaderCourseDetail = async ({ params } : { params:Params}) => { + const { courseId } = params; + if (!courseId) { + throw new Error("Course ID is undefined."); + } + const course = await dataLoaderCourse(courseId); + const projects = await dataLoaderProjects(courseId); + const admins = await dataLoaderAdmins(courseId); + const students = await dataLoaderStudents(courseId); + + return { course, projects, admins, students }; +}; \ No newline at end of file diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index 3b23815a..e47f5354 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -137,9 +137,7 @@ export default function ProjectForm() { const fetchCourses = async () => { const response = await fetch(`${apiUrl}/courses?teacher=${user}`, { - headers: { - "Authorization": user - }, + credentials: 'include' }) const jsonData = await response.json(); if (jsonData.data) { @@ -206,9 +204,7 @@ export default function ProjectForm() { const response = await fetch(`${apiUrl}/projects`, { method: "post", - headers: { - "Authorization": user - }, + credentials: 'include', body: formData }) diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx index eae8b416..f0937995 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx @@ -5,7 +5,6 @@ import ProjectSubmissionsOverviewDatagrid from "./ProjectSubmissionOverviewDatag import download from 'downloadjs'; import {useTranslation} from "react-i18next"; const apiUrl = import.meta.env.VITE_API_HOST -const user = "teacher" /** * @returns Overview page for submissions @@ -20,9 +19,7 @@ export default function ProjectSubmissionOverview() { const fetchProject = async () => { const response = await fetch(`${apiUrl}/projects/${projectId}`, { - headers: { - "Authorization": user - }, + credentials: 'include' }) const jsonData = await response.json(); setProjectTitle(jsonData["data"].title); @@ -31,9 +28,7 @@ export default function ProjectSubmissionOverview() { const downloadProjectSubmissions = async () => { await fetch(`${apiUrl}/projects/${projectId}/submissions-download`, { - headers: { - "Authorization": user - }, + credentials: 'include', }) .then(res => { return res.blob(); diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx index a3d02d8f..0c5fb167 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx @@ -9,7 +9,6 @@ import DownloadIcon from '@mui/icons-material/Download'; import download from "downloadjs"; const apiUrl = import.meta.env.VITE_API_HOST -const user = "teacher" interface Submission { grading: string; @@ -30,9 +29,7 @@ function getRowId(row: Submission) { const fetchSubmissionsFromUser = async (submission_id: string) => { await fetch(`${apiUrl}/submissions/${submission_id}/download`, { - headers: { - "Authorization": user - }, + credentials: 'include', }) .then(res => { return res.blob(); @@ -87,9 +84,7 @@ export default function ProjectSubmissionsOverviewDatagrid() { const fetchLastSubmissionsByUser = async () => { const response = await fetch(`${apiUrl}/projects/${projectId}/latest-per-user`, { - headers: { - "Authorization": user - }, + credentials: 'include', }) const jsonData = await response.json(); setSubmissions(jsonData.data); diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 8ce28644..65053f4d 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -35,14 +35,14 @@ export default function ProjectView() { useEffect(() => { fetch(`${API_URL}/projects/${projectId}`, { - headers: { Authorization: "teacher" }, + credentials: 'include', }).then((response) => { if (response.ok) { response.json().then((data) => { const projectData = data["data"]; setProjectData(projectData); fetch(`${API_URL}/courses/${projectData.course_id}`, { - headers: { Authorization: "teacher" }, + credentials: 'include', }).then((response) => { if (response.ok) { response.json().then((data) => { @@ -55,7 +55,7 @@ export default function ProjectView() { }); fetch(`${API_URL}/projects/${projectId}/assignment`, { - headers: { Authorization: "teacher" }, + credentials: 'include', }).then((response) => { if (response.ok) { response.text().then((data) => setAssignmentRawText(data)); diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx index c223aa27..84016e54 100644 --- a/frontend/src/pages/project/projectView/SubmissionCard.tsx +++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx @@ -48,7 +48,7 @@ export default function SubmissionCard({ useEffect(() => { fetch(`${submissionUrl}?project_id=${projectId}`, { - headers: { Authorization: "teacher" } + credentials: 'include' }).then((response) => { if (response.ok) { response.json().then((data) => { diff --git a/frontend/src/pages/project/projectView/SubmissionsGrid.tsx b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx index 46a56f73..93bf14ed 100644 --- a/frontend/src/pages/project/projectView/SubmissionsGrid.tsx +++ b/frontend/src/pages/project/projectView/SubmissionsGrid.tsx @@ -42,7 +42,7 @@ export default function SubmissionsGrid({ headerName: t("submitTime"), type: "string", flex: 1, - valueFormatter: (value) => timeDifference(value), + valueFormatter: (value) => timeDifference(value, true), }, { field: "submission_status", diff --git a/frontend/src/utils/date-utils.ts b/frontend/src/utils/date-utils.ts index 59fe2db9..e1330607 100644 --- a/frontend/src/utils/date-utils.ts +++ b/frontend/src/utils/date-utils.ts @@ -5,23 +5,37 @@ import i18next from "i18next"; * @param date - date string to be converted to time difference * @returns - time difference between the current date and the given date */ -export function timeDifference(date: string) { +export function timeDifference(date: string, past: boolean = false) { const t = (key: string) => { return i18next.t(`time.${key}`); }; const current = new Date(); const previous = new Date(date); - const diff = current.getTime() - previous.getTime(); + let diff = 0; + if (past) { + diff = current.getTime() - previous.getTime(); + } else { + diff = previous.getTime() - current.getTime(); + } const minutes = Math.floor(diff / 60000); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); const months = Math.floor(days / 30); const years = Math.floor(months / 12); - if (years > 0) return `${years} ${t("yearsAgo")}`; - if (months > 0) return `${months} ${t("monthsAgo")}`; - if (days > 0) return `${days} ${t("daysAgo")}`; - if (hours > 0) return `${hours} ${t("hoursAgo")}`; - if (minutes > 0) return `${minutes} ${t("minutesAgo")}`; + if (past) { + if (years > 0) return `${years} ${t("yearsAgo")}`; + if (months > 0) return `${months} ${t("monthsAgo")}`; + if (days > 0) return `${days} ${t("daysAgo")}`; + if (hours > 0) return `${hours} ${t("hoursAgo")}`; + if (minutes > 0) return `${minutes} ${t("minutesAgo")}`; + } + else{ + if (years > 0) return `${years} ${t("yearsLater")}`; + if (months > 0) return `${months} ${t("monthsLater")}`; + if (days > 0) return `${days} ${t("daysLater")}`; + if (hours > 0) return `${hours} ${t("hoursLater")}`; + if (minutes > 0) return `${minutes} ${t("minutesLater")}`; + } return t("justNow"); } From 1a5e076b4de693f1b483bea5ceddf50c34b0556d Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:07:47 +0200 Subject: [PATCH 287/377] Fix authentication bugs (#226) * fixed invalid_token_loader and added function to get csrf_cookie * changed name of file to be correct * small fixes * added csrf to all fetches * forgot an import * formatting * formatting * added custom fetch function * removed e --- .../project/endpoints/authentication/auth.py | 6 + backend/project/init_auth.py | 2 +- .../Courses/CourseDetailTeacher.tsx | 28 +- .../Courses/CourseUtilComponents.tsx | 268 +++++++++++++----- .../src/components/Courses/CourseUtils.tsx | 90 +++--- frontend/src/components/Header/Login.tsx | 2 +- .../components/ProjectForm/ProjectForm.tsx | 10 +- .../ProjectSubmissionOverview.tsx | 9 +- .../ProjectSubmissionOverviewDatagrid.tsx | 9 +- frontend/src/pages/project/FetchProjects.tsx | 195 +++++++------ .../pages/project/projectView/ProjectView.tsx | 13 +- .../project/projectView/SubmissionCard.tsx | 5 +- frontend/src/utils/authenticated-fetch.ts | 17 ++ frontend/src/utils/csrf.ts | 11 + 14 files changed, 406 insertions(+), 259 deletions(-) create mode 100644 frontend/src/utils/authenticated-fetch.ts create mode 100644 frontend/src/utils/csrf.ts diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py index 86bf96dc..9d078139 100644 --- a/backend/project/endpoints/authentication/auth.py +++ b/backend/project/endpoints/authentication/auth.py @@ -45,6 +45,12 @@ def microsoft_authentication(): res = requests.post(f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token", data=data, timeout=5) + if res.status_code != 200: + abort(make_response(( + {"message": + "An error occured while trying to authenticate your access token"}, + 500))) + # hier wel nog if om error zelf op te vangen token = res.json()["access_token"] profile_res = requests.get("https://graph.microsoft.com/v1.0/me", headers={"Authorization":f"Bearer {token}"}, diff --git a/backend/project/init_auth.py b/backend/project/init_auth.py index bc20c497..5d4d6356 100644 --- a/backend/project/init_auth.py +++ b/backend/project/init_auth.py @@ -19,7 +19,7 @@ def expired_token_callback(jwt_header, jwt_payload): 401) @jwt.invalid_token_loader - def invalid_token_callback(jwt_header, jwt_payload): + def invalid_token_callback(jwt_header): return ( {"message":("The server cannot recognize this access token cookie, " "please log in again if you think this is an error")}, diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index e1cf0ff3..cd729766 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -6,6 +6,7 @@ import { Link, useNavigate, NavigateFunction, useLoaderData } from "react-router import { Title } from "../Header/Title"; import ClearIcon from '@mui/icons-material/Clear'; import { timeDifference } from "../../utils/date-utils"; +import { authenticatedFetch } from "../../utils/authenticated-fetch"; interface UserUid{ uid: string @@ -18,12 +19,8 @@ interface UserUid{ * @param uid - The UID of the admin. */ function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: string): void { - fetch(`${apiHost}/courses/${courseId}/admins`, { + authenticatedFetch(`${apiHost}/courses/${courseId}/admins`, { method: 'DELETE', - credentials: 'include', - headers: { - "Content-Type": "application/json" - }, body: JSON.stringify({ "admin_uid": uid }) @@ -40,11 +37,10 @@ function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: st * @param uid - The UID of the admin. */ function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: string[]): void { - fetch(`${apiHost}/courses/${courseId}/students`, { + authenticatedFetch(`${apiHost}/courses/${courseId}/students`, { method: 'DELETE', - credentials: 'include', headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ "students": uids @@ -61,9 +57,8 @@ function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: * @param courseId - The ID of the course. */ function handleDeleteCourse(navigate: NavigateFunction, courseId: string): void { - fetch(`${apiHost}/courses/${courseId}`, { + authenticatedFetch(`${apiHost}/courses/${courseId}`, { method: 'DELETE', - credentials: 'include', }).then((response) => { if(response.ok){ navigate(-1); @@ -289,9 +284,8 @@ function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, o }; const getCodes = useCallback(() => { - fetch(`${apiHost}/courses/${courseId}/join_codes`, { + authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes`, { method: 'GET', - credentials: 'include', }) .then(response => response.json()) .then(data => { @@ -317,11 +311,10 @@ function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, o bodyContent.expiry_time = expiry_time.toISOString(); } - fetch(`${apiHost}/courses/${courseId}/join_codes`, { + authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes`, { method: 'POST', - credentials: 'include', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', }, body: JSON.stringify(bodyContent) }) @@ -329,12 +322,11 @@ function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, o } const handleDeleteCode = (joinCode: string) => { - fetch(`${apiHost}/courses/${courseId}/join_codes/${joinCode}`, + authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes/${joinCode}`, { method: 'DELETE', - credentials: 'include', headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ "join_code": joinCode diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx index 6a9e4804..48d6bdb2 100644 --- a/frontend/src/components/Courses/CourseUtilComponents.tsx +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -1,17 +1,45 @@ -import { Box, Button, Card, CardActions, CardContent, CardHeader, Grid, Paper, TextField, Typography } from "@mui/material"; -import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate } from "./CourseUtils"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + Grid, + Paper, + TextField, + Typography, +} from "@mui/material"; +import { + Course, + Project, + apiHost, + getIdFromLink, + getNearestFutureDate, +} from "./CourseUtils"; import { Link, useNavigate, useLocation } from "react-router-dom"; import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import debounce from 'debounce'; +import debounce from "debounce"; +import { authenticatedFetch } from "../../utils/authenticated-fetch"; /** * @param text - The text to be displayed * @returns Typography that overflow into ... when text is too long */ -export function EpsilonTypography({text} : {text: string}): JSX.Element { +export function EpsilonTypography({ text }: { text: string }): JSX.Element { return ( - {text} + + {text} + ); } @@ -21,7 +49,15 @@ export function EpsilonTypography({text} : {text: string}): JSX.Element { * @param handleSearchChange - The function to handle search term changes * @returns a Grid item containing a TextField, used for searching/filtering */ -export function SearchBox({label,searchTerm,handleSearchChange}: {label: string, searchTerm: string, handleSearchChange: (event: React.ChangeEvent) => void}): JSX.Element { +export function SearchBox({ + label, + searchTerm, + handleSearchChange, +}: { + label: string; + searchTerm: string; + handleSearchChange: (event: React.ChangeEvent) => void; +}): JSX.Element { return ( @@ -41,43 +77,57 @@ export function SearchBox({label,searchTerm,handleSearchChange}: {label: string, * @param props - The component props requiring the courses that will be displayed in the scroller. * @returns A component to display courses in horizontal scroller where each course is a card containing its name. */ -export function SideScrollableCourses({courses}: {courses: Course[]}): JSX.Element { +export function SideScrollableCourses({ + courses, +}: { + courses: Course[]; +}): JSX.Element { //const navigate = useNavigate(); const location = useLocation(); const navigate = useNavigate(); // Get initial state from URL - const urlParams = useMemo(() => new URLSearchParams(location.search), [location.search]); //useMemo so only recompute when location.search changes - const initialSearchTerm = urlParams.get('name') || ''; - const initialUforaIdFilter = urlParams.get('ufora_id') || ''; - const initialTeacherNameFilter = urlParams.get('teacher') || ''; + const urlParams = useMemo( + () => new URLSearchParams(location.search), + [location.search] + ); //useMemo so only recompute when location.search changes + const initialSearchTerm = urlParams.get("name") || ""; + const initialUforaIdFilter = urlParams.get("ufora_id") || ""; + const initialTeacherNameFilter = urlParams.get("teacher") || ""; const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [uforaIdFilter, setUforaIdFilter] = useState(initialUforaIdFilter); - const [teacherNameFilter, setTeacherNameFilter] = useState(initialTeacherNameFilter); - const [projects, setProjects] = useState<{ [courseId: string]: Project[] }>({}); - - const debouncedHandleSearchChange = useMemo(() => - debounce((key: string, value: string) => { - if (value === '') { - urlParams.delete(key); - } else { - urlParams.set(key, value); - } - const newUrl = `${location.pathname}?${urlParams.toString()}`; - navigate(newUrl, { replace: true }); - }, 500), [urlParams, navigate, location.pathname]); + const [teacherNameFilter, setTeacherNameFilter] = useState( + initialTeacherNameFilter + ); + const [projects, setProjects] = useState<{ [courseId: string]: Project[] }>( + {} + ); + + const debouncedHandleSearchChange = useMemo( + () => + debounce((key: string, value: string) => { + if (value === "") { + urlParams.delete(key); + } else { + urlParams.set(key, value); + } + const newUrl = `${location.pathname}?${urlParams.toString()}`; + navigate(newUrl, { replace: true }); + }, 500), + [urlParams, navigate, location.pathname] + ); useEffect(() => { - debouncedHandleSearchChange('name', searchTerm); + debouncedHandleSearchChange("name", searchTerm); }, [searchTerm, debouncedHandleSearchChange]); useEffect(() => { - debouncedHandleSearchChange('ufora_id', uforaIdFilter); + debouncedHandleSearchChange("ufora_id", uforaIdFilter); }, [uforaIdFilter, debouncedHandleSearchChange]); useEffect(() => { - debouncedHandleSearchChange('teacher', teacherNameFilter); + debouncedHandleSearchChange("teacher", teacherNameFilter); }, [teacherNameFilter, debouncedHandleSearchChange]); const handleSearchChange = (event: React.ChangeEvent) => { @@ -85,12 +135,16 @@ export function SideScrollableCourses({courses}: {courses: Course[]}): JSX.Eleme setSearchTerm(newSearchTerm); }; - const handleUforaIdFilterChange = (event: React.ChangeEvent) => { + const handleUforaIdFilterChange = ( + event: React.ChangeEvent + ) => { const newUforaIdFilter = event.target.value; setUforaIdFilter(newUforaIdFilter); }; - const handleTeacherNameFilterChange = (event: React.ChangeEvent) => { + const handleTeacherNameFilterChange = ( + event: React.ChangeEvent + ) => { const newTeacherNameFilter = event.target.value; setTeacherNameFilter(newTeacherNameFilter); }; @@ -98,11 +152,10 @@ export function SideScrollableCourses({courses}: {courses: Course[]}): JSX.Eleme useEffect(() => { // Fetch projects for each course const fetchProjects = async () => { - const projectPromises = courses.map(course => - fetch(`${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}`, - { credentials: 'include' } - ) - .then(response => response.json()) + const projectPromises = courses.map((course) => + authenticatedFetch( + `${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}` + ).then((response) => response.json()) ); const projectResults = await Promise.all(projectPromises); @@ -118,59 +171,105 @@ export function SideScrollableCourses({courses}: {courses: Course[]}): JSX.Eleme fetchProjects(); }, [courses]); - const filteredCourses = courses.filter(course => - course.name.toLowerCase().includes(searchTerm.toLowerCase()) && - (course.ufora_id ? course.ufora_id.toLowerCase().includes(uforaIdFilter.toLowerCase()) : !uforaIdFilter) && - course.teacher.toLowerCase().includes(teacherNameFilter.toLowerCase()) + const filteredCourses = courses.filter( + (course) => + course.name.toLowerCase().includes(searchTerm.toLowerCase()) && + (course.ufora_id + ? course.ufora_id.toLowerCase().includes(uforaIdFilter.toLowerCase()) + : !uforaIdFilter) && + course.teacher.toLowerCase().includes(teacherNameFilter.toLowerCase()) ); return ( - - - + + + - + ); } /** - * Empty or not. + * Empty or not. * @returns either a place holder or the actual content. */ -function EmptyOrNotFilteredCourses({filteredCourses, projects}: {filteredCourses: Course[], projects: { [courseId: string]: Project[] }}): JSX.Element { - const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); - if(filteredCourses.length === 0){ +function EmptyOrNotFilteredCourses({ + filteredCourses, + projects, +}: { + filteredCourses: Course[]; + projects: { [courseId: string]: Project[] }; +}): JSX.Element { + const { t } = useTranslation("translation", { + keyPrefix: "courseDetailTeacher", + }); + if (filteredCourses.length === 0) { return ( - {t('noCoursesFound')} + + {t("noCoursesFound")} + ); } return ( - + {filteredCourses.map((course, index) => ( - - }/> - - {course.ufora_id && ( - <> - Ufora_id: {course.ufora_id}
- - )} - Teacher: {course.teacher} - - }/> - - {t('projects')}: - + + } /> + + {course.ufora_id && ( + <> + Ufora_id: {course.ufora_id} +
+ + )} + Teacher: {course.teacher} + + } + /> + + {t("projects")}: + - + + +
@@ -180,36 +279,51 @@ function EmptyOrNotFilteredCourses({filteredCourses, projects}: {filteredCourses ); } /** - * @param projects - The projects to be displayed if not empty + * @param projects - The projects to be displayed if not empty * @returns either a place holder with text for no projects or the projects */ -function EmptyOrNotProjects({projects, noProjectsText}: {projects: Project[], noProjectsText:string}): JSX.Element { - if(projects === undefined || projects.length === 0){ +function EmptyOrNotProjects({ + projects, + noProjectsText, +}: { + projects: Project[]; + noProjectsText: string; +}): JSX.Element { + if (projects === undefined || projects.length === 0) { return ( - {noProjectsText} + + {noProjectsText} + ); - } - else{ + } else { const now = new Date(); return ( <> {projects.slice(0, 3).map((project) => { - let timeLeft = ''; + let timeLeft = ""; if (project.deadlines != undefined) { const deadlineDate = getNearestFutureDate(project.deadlines); - if(deadlineDate == null){ - return <> + if (deadlineDate == null) { + return <>; } const diffTime = Math.abs(deadlineDate.getTime() - now.getTime()); const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); const diffDays = Math.ceil(diffHours * 24); - + timeLeft = diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; } return ( - - + + ); @@ -217,4 +331,4 @@ function EmptyOrNotProjects({projects, noProjectsText}: {projects: Project[], no ); } -} \ No newline at end of file +} diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 51a462a2..331b5ea5 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -1,17 +1,18 @@ -import { NavigateFunction, Params } from 'react-router-dom'; - -export interface Course{ - course_id: string, - name: string, - teacher:string, - ufora_id:string, - url:string +import { NavigateFunction, Params } from "react-router-dom"; +import { authenticatedFetch } from "../../utils/authenticated-fetch"; + +export interface Course { + course_id: string; + name: string; + teacher: string; + ufora_id: string; + url: string; } -export interface Project{ - title: string, - project_id: string, - deadlines: string[][] +export interface Project { + title: string; + project_id: string; + deadlines: string[][]; } export const apiHost = import.meta.env.VITE_APP_API_HOST; @@ -19,7 +20,7 @@ export const appHost = import.meta.env.VITE_APP_HOST; /** * @returns The uid of the acces token of the logged in user */ -export function loggedInToken(){ +export function loggedInToken() { return "teacher1"; } @@ -35,7 +36,7 @@ export function getUserName(uid: string): string { /** * @returns The Uid of the logged in user */ -export function loggedInUid(){ +export function loggedInUid() { return "Gunnar"; } @@ -44,36 +45,37 @@ export function loggedInUid(){ * @param data - course data to send to the api * @param navigate - function that allows the app to redirect */ -export function callToApiToCreateCourse(data: string, navigate: NavigateFunction){ - fetch(`${apiHost}/courses`, { - credentials: 'include', // include, *same-origin, omit +export function callToApiToCreateCourse( + data: string, + navigate: NavigateFunction +) { + authenticatedFetch(`${apiHost}/courses`, { headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, - method: 'POST', + method: "POST", body: data, }) - .then(response => response.json()) - .then(data => { + .then((response) => response.json()) + .then((data) => { //But first also make sure that teacher is in the course admins list - fetch(`${apiHost}/courses/${getIdFromLink(data.url)}/admins`, { - credentials: 'include', - method: 'POST', + authenticatedFetch(`${apiHost}/courses/${getIdFromLink(data.url)}/admins`, { + method: "POST", headers: { - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, - body: JSON.stringify({admin_uid: loggedInUid()}) + body: JSON.stringify({ admin_uid: loggedInUid() }), }); navigate(getIdFromLink(data.url)); // navigate to data.url - }) + }); } - + /** * @param link - the link to the api endpoint * @returns the Id at the end of the link */ export function getIdFromLink(link: string): string { - const parts = link.split('/'); + const parts = link.split("/"); return parts[parts.length - 1]; } @@ -84,9 +86,13 @@ export function getIdFromLink(link: string): string { */ export function getNearestFutureDate(dates: string[][]): Date | null { const now = new Date(); - const futureDates = dates.map(date => new Date(date[1])).filter(date => date > now); + const futureDates = dates + .map((date) => new Date(date[1])) + .filter((date) => date > now); if (futureDates.length === 0) return null; - return futureDates.reduce((nearest, current) => current < nearest ? current : nearest); + return futureDates.reduce((nearest, current) => + current < nearest ? current : nearest + ); } /** @@ -96,14 +102,12 @@ export function getNearestFutureDate(dates: string[][]): Date | null { const fetchData = async (url: string, params?: URLSearchParams) => { let uri = `${apiHost}/${url}`; - if(params){ - uri += `?${params}` + if (params) { + uri += `?${params}`; } - const res = await fetch(uri, { - credentials: 'include' - }); - if(res.status !== 200){ - throw new Response("Failed to fetch data", {status: res.status}); + const res = await authenticatedFetch(uri); + if (res.status !== 200) { + throw new Response("Failed to fetch data", { status: res.status }); } const jsonResult = await res.json(); @@ -132,7 +136,11 @@ const dataLoaderStudents = async (courseId: string) => { return fetchData(`courses/${courseId}/students`); }; -export const dataLoaderCourseDetail = async ({ params } : { params:Params}) => { +export const dataLoaderCourseDetail = async ({ + params, +}: { + params: Params; +}) => { const { courseId } = params; if (!courseId) { throw new Error("Course ID is undefined."); @@ -141,6 +149,6 @@ export const dataLoaderCourseDetail = async ({ params } : { params:Params}) => { const projects = await dataLoaderProjects(courseId); const admins = await dataLoaderAdmins(courseId); const students = await dataLoaderStudents(courseId); - + return { course, projects, admins, students }; -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Header/Login.tsx b/frontend/src/components/Header/Login.tsx index d06bf021..496273c3 100644 --- a/frontend/src/components/Header/Login.tsx +++ b/frontend/src/components/Header/Login.tsx @@ -2,7 +2,7 @@ import {Button} from "@mui/material"; import { Link } from 'react-router-dom'; const CLIENT_ID = import.meta.env.VITE_APP_CLIENT_ID; -const REDIRECT_URI = encodeURI(import.meta.env.VITE_APP_API_HOST + "/auth"); +const REDIRECT_URI = encodeURIComponent(import.meta.env.VITE_APP_API_HOST + "/auth"); const TENANT_ID = import.meta.env.VITE_APP_TENANT_ID; /** diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index e47f5354..b9410bc5 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -29,6 +29,7 @@ import {TabContext} from "@mui/lab"; import FileStuctureForm from "./FileStructureForm.tsx"; import AdvancedRegex from "./AdvancedRegex.tsx"; import RunnerSelecter from "./RunnerSelecter.tsx"; +import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; interface Course { course_id: string; @@ -136,9 +137,7 @@ export default function ProjectForm() { } const fetchCourses = async () => { - const response = await fetch(`${apiUrl}/courses?teacher=${user}`, { - credentials: 'include' - }) + const response = await authenticatedFetch(`${apiUrl}/courses?teacher=${user}`) const jsonData = await response.json(); if (jsonData.data) { setCourses(jsonData.data); @@ -202,10 +201,9 @@ export default function ProjectForm() { formData.append("runner", runner); } - const response = await fetch(`${apiUrl}/projects`, { + const response = await authenticatedFetch(`${apiUrl}/projects`, { method: "post", - credentials: 'include', - body: formData + body: formData, }) if (!response.ok) { diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx index f0937995..2e26bb07 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx @@ -4,6 +4,7 @@ import {useParams} from "react-router-dom"; import ProjectSubmissionsOverviewDatagrid from "./ProjectSubmissionOverviewDatagrid.tsx"; import download from 'downloadjs'; import {useTranslation} from "react-i18next"; +import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; const apiUrl = import.meta.env.VITE_API_HOST /** @@ -18,18 +19,14 @@ export default function ProjectSubmissionOverview() { }); const fetchProject = async () => { - const response = await fetch(`${apiUrl}/projects/${projectId}`, { - credentials: 'include' - }) + const response = await authenticatedFetch(`${apiUrl}/projects/${projectId}`) const jsonData = await response.json(); setProjectTitle(jsonData["data"].title); } const downloadProjectSubmissions = async () => { - await fetch(`${apiUrl}/projects/${projectId}/submissions-download`, { - credentials: 'include', - }) + await authenticatedFetch(`${apiUrl}/projects/${projectId}/submissions-download`) .then(res => { return res.blob(); }) diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx index 0c5fb167..a617d47e 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx @@ -7,6 +7,7 @@ import { green, red } from '@mui/material/colors'; import CancelIcon from '@mui/icons-material/Cancel'; import DownloadIcon from '@mui/icons-material/Download'; import download from "downloadjs"; +import { authenticatedFetch } from "../../utils/authenticated-fetch"; const apiUrl = import.meta.env.VITE_API_HOST @@ -28,9 +29,7 @@ function getRowId(row: Submission) { } const fetchSubmissionsFromUser = async (submission_id: string) => { - await fetch(`${apiUrl}/submissions/${submission_id}/download`, { - credentials: 'include', - }) + await authenticatedFetch(`${apiUrl}/submissions/${submission_id}/download`) .then(res => { return res.blob(); }) @@ -83,9 +82,7 @@ export default function ProjectSubmissionsOverviewDatagrid() { }); const fetchLastSubmissionsByUser = async () => { - const response = await fetch(`${apiUrl}/projects/${projectId}/latest-per-user`, { - credentials: 'include', - }) + const response = await authenticatedFetch(`${apiUrl}/projects/${projectId}/latest-per-user`) const jsonData = await response.json(); setSubmissions(jsonData.data); } diff --git a/frontend/src/pages/project/FetchProjects.tsx b/frontend/src/pages/project/FetchProjects.tsx index 81444303..dd4ba700 100644 --- a/frontend/src/pages/project/FetchProjects.tsx +++ b/frontend/src/pages/project/FetchProjects.tsx @@ -1,114 +1,127 @@ -import {Project, ProjectDeadline, ShortSubmission} from "./projectDeadline/ProjectDeadline.tsx"; -const API_URL = import.meta.env.VITE_APP_API_HOST +import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; +import { + Project, + ProjectDeadline, + ShortSubmission, +} from "./projectDeadline/ProjectDeadline.tsx"; +const API_URL = import.meta.env.VITE_APP_API_HOST; export const fetchProjectPage = async () => { - const projects = await fetchProjects() - const me = await fetchMe() - return {projects, me} -} + const projects = await fetchProjects(); + const me = await fetchMe(); + return { projects, me }; +}; export const fetchMe = async () => { try { - const response = await fetch(`${API_URL}/me`, { - credentials: 'include' - }) - if(response.status == 200){ - const data = await response.json() - return data.role - }else { - return "UNKNOWN" + const response = await authenticatedFetch(`${API_URL}/me`); + if (response.status == 200) { + const data = await response.json(); + return data.role; + } else { + return "UNKNOWN"; } - } catch (e){ - return "UNKNOWN" + } catch (e) { + return "UNKNOWN"; } - -} +}; export const fetchProjects = async () => { - - try{ - const response = await fetch(`${API_URL}/projects`, { - credentials: 'include' - - }) + try { + const response = await authenticatedFetch(`${API_URL}/projects`); const jsonData = await response.json(); - let formattedData: ProjectDeadline[] = await Promise.all( jsonData.data.map(async (item:Project) => { - try{ - const url_split = item.project_id.split('/') - const project_id = url_split[url_split.length -1] - const response_submissions = await (await fetch(encodeURI(`${API_URL}/submissions?project_id=${project_id}`), { - credentials: 'include' - - })).json() + let formattedData: ProjectDeadline[] = await Promise.all( + jsonData.data.map(async (item: Project) => { + try { + const url_split = item.project_id.split("/"); + const project_id = url_split[url_split.length - 1]; + const response_submissions = await ( + await authenticatedFetch( + encodeURI(`${API_URL}/submissions?project_id=${project_id}`) + ) + ).json(); - //get the latest submission - const latest_submission = response_submissions.data.map((submission:ShortSubmission) => ({ - submission_id: submission.submission_id,//this is the path - submission_time: new Date(submission.submission_time), - submission_status: submission.submission_status, - grading: submission.grading - } - )).sort((a:ShortSubmission, b:ShortSubmission) => b.submission_time.getTime() - a.submission_time.getTime())[0]; - // fetch the course id of the project - const project_item = await (await fetch(encodeURI(`${API_URL}/projects/${project_id}`), { credentials: 'include' - })).json() + //get the latest submission + const latest_submission = response_submissions.data + .map((submission: ShortSubmission) => ({ + submission_id: submission.submission_id, //this is the path + submission_time: new Date(submission.submission_time), + submission_status: submission.submission_status, + grading: submission.grading, + })) + .sort( + (a: ShortSubmission, b: ShortSubmission) => + b.submission_time.getTime() - a.submission_time.getTime() + )[0]; + // fetch the course id of the project + const project_item = await ( + await authenticatedFetch( + encodeURI(`${API_URL}/projects/${project_id}`) + ) + ).json(); - //fetch the course - const response_courses = await (await fetch(encodeURI(`${API_URL}/courses/${project_item.data.course_id}`), { - credentials: 'include' - })).json() - const course = { - course_id: response_courses.data.course_id, - name: response_courses.data.name, - teacher: response_courses.data.teacher, - ufora_id: response_courses.data.ufora_id - } - if(project_item.data.deadlines){ - return project_item.data.deadlines.map((d:string[]) => { - return { + //fetch the course + const response_courses = await ( + await authenticatedFetch( + encodeURI(`${API_URL}/courses/${project_item.data.course_id}`) + ) + ).json(); + const course = { + course_id: response_courses.data.course_id, + name: response_courses.data.name, + teacher: response_courses.data.teacher, + ufora_id: response_courses.data.ufora_id, + }; + if (project_item.data.deadlines) { + return project_item.data.deadlines.map((d: string[]) => { + return { + project_id: project_id, + title: project_item.data.title, + description: project_item.data.description, + assignment_file: project_item.data.assignment_file, + deadline: new Date(d[1]), + deadline_description: d[0], + course_id: Number(project_item.data.course_id), + visible_for_students: Boolean( + project_item.data.visible_for_students + ), + archived: Boolean(project_item.data.archived), + test_path: project_item.data.test_path, + script_name: project_item.data.script_name, + regex_expressions: project_item.data.regex_expressions, + short_submission: latest_submission, + course: course, + }; + }); + } + // contains no dealine: + return [ + { project_id: project_id, title: project_item.data.title, description: project_item.data.description, assignment_file: project_item.data.assignment_file, - deadline: new Date(d[1]), - deadline_description: d[0], + deadline: undefined, + deadline_description: undefined, course_id: Number(project_item.data.course_id), - visible_for_students: Boolean(project_item.data.visible_for_students), + visible_for_students: Boolean( + project_item.data.visible_for_students + ), archived: Boolean(project_item.data.archived), test_path: project_item.data.test_path, script_name: project_item.data.script_name, regex_expressions: project_item.data.regex_expressions, short_submission: latest_submission, - course: course - } - }) + course: course, + }, + ]; + } catch (e) { + return []; } - // contains no dealine: - return [{ - project_id: project_id, - title: project_item.data.title, - description: project_item.data.description, - assignment_file: project_item.data.assignment_file, - deadline: undefined, - deadline_description: undefined, - course_id: Number(project_item.data.course_id), - visible_for_students: Boolean(project_item.data.visible_for_students), - archived: Boolean(project_item.data.archived), - test_path: project_item.data.test_path, - script_name: project_item.data.script_name, - regex_expressions: project_item.data.regex_expressions, - short_submission: latest_submission, - course: course - }] - - }catch (e){ - return [] - } - } - - )); - formattedData = formattedData.flat() - return formattedData - } catch (e) { - return [] + }) + ); + formattedData = formattedData.flat(); + return formattedData; + } catch (_) { + return []; } -} +}; diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 65053f4d..80335194 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -14,6 +14,7 @@ import { useParams } from "react-router-dom"; import SubmissionCard from "./SubmissionCard"; import { Course } from "../../../types/course"; import { Title } from "../../../components/Header/Title"; +import { authenticatedFetch } from "../../../utils/authenticated-fetch"; const API_URL = import.meta.env.VITE_API_HOST; @@ -34,16 +35,12 @@ export default function ProjectView() { const [assignmentRawText, setAssignmentRawText] = useState(""); useEffect(() => { - fetch(`${API_URL}/projects/${projectId}`, { - credentials: 'include', - }).then((response) => { + authenticatedFetch(`${API_URL}/projects/${projectId}`).then((response) => { if (response.ok) { response.json().then((data) => { const projectData = data["data"]; setProjectData(projectData); - fetch(`${API_URL}/courses/${projectData.course_id}`, { - credentials: 'include', - }).then((response) => { + authenticatedFetch(`${API_URL}/courses/${projectData.course_id}`).then((response) => { if (response.ok) { response.json().then((data) => { setCourseData(data["data"]); @@ -54,9 +51,7 @@ export default function ProjectView() { } }); - fetch(`${API_URL}/projects/${projectId}/assignment`, { - credentials: 'include', - }).then((response) => { + authenticatedFetch(`${API_URL}/projects/${projectId}/assignment`).then((response) => { if (response.ok) { response.text().then((data) => setAssignmentRawText(data)); } diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx index 84016e54..d9d8a898 100644 --- a/frontend/src/pages/project/projectView/SubmissionCard.tsx +++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx @@ -16,6 +16,7 @@ import axios from "axios"; import { useTranslation } from "react-i18next"; import SubmissionsGrid from "./SubmissionsGrid"; import { Submission } from "../../../types/submission"; +import { authenticatedFetch } from "../../../utils/authenticated-fetch"; interface SubmissionCardProps { regexRequirements?: string[]; @@ -47,9 +48,7 @@ export default function SubmissionCard({ useEffect(() => { - fetch(`${submissionUrl}?project_id=${projectId}`, { - credentials: 'include' - }).then((response) => { + authenticatedFetch(`${submissionUrl}?project_id=${projectId}`).then((response) => { if (response.ok) { response.json().then((data) => { setPreviousSubmissions(data["data"]); diff --git a/frontend/src/utils/authenticated-fetch.ts b/frontend/src/utils/authenticated-fetch.ts new file mode 100644 index 00000000..84f1b2e0 --- /dev/null +++ b/frontend/src/utils/authenticated-fetch.ts @@ -0,0 +1,17 @@ +import { getCSRFCookie } from "./csrf"; + +/** + * A helper function to automatically add the necessary authentication options to fetch + * @returns the result of the fetch with given options and default authentication options included + */ +export function authenticatedFetch( + url: string, + init?: RequestInit +): Promise { + const update = { ...init, credentials: "include"}; + update.headers = { + ...update.headers, + "X-CSRF-TOKEN": getCSRFCookie() + } + return fetch(url, Object.assign(update)); +} diff --git a/frontend/src/utils/csrf.ts b/frontend/src/utils/csrf.ts new file mode 100644 index 00000000..195e98f3 --- /dev/null +++ b/frontend/src/utils/csrf.ts @@ -0,0 +1,11 @@ +/** + * A helper function to easily retrieve the crsf_access_token cookie + * @returns the crsf_access_token cookie + */ +export function getCSRFCookie(): string { + const cookie = document.cookie + .split("; ") + .find((row) => row.startsWith("csrf_access_token=")) + ?.split("=")[1]; + return cookie ? cookie : ""; +} \ No newline at end of file From 4a35f4fd121d0be9cdb8b5763ae331f1d87a9653 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:25:53 +0200 Subject: [PATCH 288/377] Frontend/enhancement/join code endpoint (#248) * Fix #247 * fixed linting * using authenticated fetch * unused import --- frontend/src/App.tsx | 2 ++ frontend/src/loaders/join-code.ts | 27 +++++++++++++++++++++++ frontend/src/utils/authenticated-fetch.ts | 2 +- 3 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 frontend/src/loaders/join-code.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index efe17395..012b7457 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import SubmissionsOverview from "./pages/submission_overview/SubmissionsOverview import {fetchProjectPage} from "./pages/project/FetchProjects.tsx"; import HomePages from "./pages/home/HomePages.tsx"; import ProjectOverView from "./pages/project/projectOverview.tsx"; +import { synchronizeJoinCode } from "./loaders/join-code.ts"; const router = createBrowserRouter( createRoutesFromElements( @@ -25,6 +26,7 @@ const router = createBrowserRouter( } loader={dataLoaderCourses}/> + } loader={dataLoaderCourseDetail} /> diff --git a/frontend/src/loaders/join-code.ts b/frontend/src/loaders/join-code.ts new file mode 100644 index 00000000..3e693d8c --- /dev/null +++ b/frontend/src/loaders/join-code.ts @@ -0,0 +1,27 @@ +import { redirect } from "react-router-dom"; +import i18next from "i18next"; +import { authenticatedFetch } from "../utils/authenticated-fetch"; + +const API_URL = import.meta.env.VITE_APP_API_HOST; + +/** + * This function sends a request to the server to join a course with a given join code. + * @returns - Redirects to the course page if the join code is valid + */ +export async function synchronizeJoinCode() { + const queryParams = new URLSearchParams(window.location.search); + const joinCode = queryParams.get("code"); + + if (joinCode) { + const response = await authenticatedFetch(new URL("/courses/join", API_URL)); + + if (response.ok) { + const responseData = await response.json(); + return redirect( + `/${i18next.language}/courses/${responseData.data.course_id}` + ); + } + } else { + throw new Error("No join code provided"); + } +} diff --git a/frontend/src/utils/authenticated-fetch.ts b/frontend/src/utils/authenticated-fetch.ts index 84f1b2e0..b18b03d5 100644 --- a/frontend/src/utils/authenticated-fetch.ts +++ b/frontend/src/utils/authenticated-fetch.ts @@ -5,7 +5,7 @@ import { getCSRFCookie } from "./csrf"; * @returns the result of the fetch with given options and default authentication options included */ export function authenticatedFetch( - url: string, + url: string | URL | globalThis.Request, init?: RequestInit ): Promise { const update = { ...init, credentials: "include"}; From afe3893c866c957d5345799c3c4b921aef76e4c4 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 25 Apr 2024 17:50:41 +0200 Subject: [PATCH 289/377] Fixes several buggs in join code endpoint (#251) * Fix #249 * linting --- backend/project/endpoints/courses/join.py | 21 ++++++++++++++++----- backend/project/utils/misc.py | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/courses/join.py b/backend/project/endpoints/courses/join.py index 16ed002f..6a3c70ec 100644 --- a/backend/project/endpoints/courses/join.py +++ b/backend/project/endpoints/courses/join.py @@ -5,7 +5,6 @@ from os import getenv from datetime import datetime -from zoneinfo import ZoneInfo from flask import request from flask_restful import Resource @@ -14,9 +13,10 @@ from project.models.course_share_code import CourseShareCode from project.models.course_relation import CourseStudent, CourseAdmin +from project.utils.misc import is_valid_uuid +from project.db_in import db +from project.utils.authentication import login_required_return_uid - -TIMEZONE = getenv("TIMEZONE", "GMT") API_URL = getenv("API_HOST") class CourseJoin(Resource): @@ -25,6 +25,7 @@ class CourseJoin(Resource): students or admins with a join code can join a course """ + @login_required_return_uid def post(self, uid=None): # pylint: disable=too-many-return-statements """ Post function for /courses/join @@ -40,13 +41,18 @@ def post(self, uid=None): # pylint: disable=too-many-return-statements return {"message": "join_code is required"}, 400 join_code = data["join_code"] + + if not is_valid_uuid(join_code): + response["message"] = "Invalid join code" + return response, 400 + share_code = CourseShareCode.query.filter_by(join_code=join_code).first() if not share_code: response["message"] = "Invalid join code" return response, 400 - if share_code.expiry_time and share_code.expiry_time < datetime.now(ZoneInfo(TIMEZONE)): + if share_code.expiry_time and share_code.expiry_time < datetime.now().date(): response["message"] = "Join code has expired" return response, 400 @@ -70,9 +76,14 @@ def post(self, uid=None): # pylint: disable=too-many-return-statements course_relation = course_relation(course_id=course_id, uid=uid) try: - course_relation.insert() + db.session.add(course_relation) + db.session.commit() + response["data"] = { + "course_id": course_id + } response["message"] = "User added to course" return response, 201 except SQLAlchemyError: + db.session.rollback() response["message"] = "Internal server error" return response, 500 diff --git a/backend/project/utils/misc.py b/backend/project/utils/misc.py index a42cfb2f..07c1a8d3 100644 --- a/backend/project/utils/misc.py +++ b/backend/project/utils/misc.py @@ -5,6 +5,7 @@ from typing import Dict, List from urllib.parse import urljoin +from uuid import UUID from sqlalchemy.ext.declarative import DeclarativeMeta @@ -73,3 +74,22 @@ def filter_model_fields(model: DeclarativeMeta, data: Dict[str, str]): A dictionary with the fields of the model. """ return {key: value for key, value in data.items() if hasattr(model, key)} + + +def is_valid_uuid(uuid_to_test, version=4): + """ + Check if uuid_to_test is a valid UUID. + + Args: + uuid_to_test: str - The UUID to test. + version: int - The version of the UUID. + + Returns: + bool: True if the UUID is valid, False otherwise. + """ + + try: + uuid_obj = UUID(uuid_to_test, version=version) + except ValueError: + return False + return str(uuid_obj) == uuid_to_test From 40db0ad056c46671411337a08d5a591d25285541 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Fri, 26 Apr 2024 12:54:56 +0200 Subject: [PATCH 290/377] fix home/header login button (#228) fix home/header login button #227 #241 --- frontend/package-lock.json | 15 ++ frontend/package.json | 5 +- frontend/public/locales/en/translation.json | 3 +- frontend/public/locales/nl/translation.json | 3 +- frontend/src/App.tsx | 44 ++++-- frontend/src/Layout.tsx | 15 -- frontend/src/components/Header/Header.tsx | 138 ++++++++++++----- frontend/src/components/Header/Layout.tsx | 9 +- frontend/src/components/Header/Login.tsx | 12 +- frontend/src/pages/home/Home.tsx | 44 +++--- frontend/src/pages/home/HomePage.tsx | 146 ++++++++++-------- frontend/src/pages/home/HomePages.tsx | 10 +- frontend/src/types/me.ts | 6 + frontend/src/utils/fetches/FetchMe.ts | 19 +++ .../fetches}/FetchProjects.tsx | 26 +--- 15 files changed, 309 insertions(+), 186 deletions(-) delete mode 100644 frontend/src/Layout.tsx create mode 100644 frontend/src/types/me.ts create mode 100644 frontend/src/utils/fetches/FetchMe.ts rename frontend/src/{pages/project => utils/fetches}/FetchProjects.tsx (88%) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fc1e44f..d8c820b3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", "jszip": "^3.10.1", + "prettier": "^3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", @@ -6450,6 +6451,20 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b1903b29..8c03f123 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,8 +15,8 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@mui/icons-material": "^5.15.15", - "@mui/material": "^5.15.15", "@mui/lab": "^5.0.0-alpha.170", + "@mui/material": "^5.15.15", "@mui/styled-engine-sc": "^6.0.0-alpha.16", "@mui/x-data-grid": "^7.1.1", "@mui/x-date-pickers": "^7.1.1", @@ -27,6 +27,7 @@ "i18next-browser-languagedetector": "^7.2.0", "i18next-http-backend": "^2.5.0", "jszip": "^3.10.1", + "prettier": "^3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^14.1.0", @@ -37,10 +38,10 @@ }, "devDependencies": { "@types/downloadjs": "^1.4.6", + "@types/history": "^4.7.11", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "@types/react-router-dom": "^5.3.3", - "@types/history": "^4.7.11", "@types/scheduler": "^0.23.0", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 212bd4cc..af275341 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -7,7 +7,8 @@ "home": "Home", "tag": "en", "homepage": "Homepage", - "projectUploadForm": "Project upload form" + "projectUploadForm": "Project upload form", + "logout": "Logout" }, "home": { "home": "Home", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 7addc7e6..21d33f59 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -6,7 +6,8 @@ "home": "Home", "tag": "nl", "homepage": "Homepage", - "projectUploadForm": "Project uploaden" + "projectUploadForm": "Project uploaden", + "logout": "Afmelden" }, "home": { "home": "Home", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 012b7457..fd8f2b7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,28 +1,44 @@ -import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from "react-router-dom"; +import { + Route, + RouterProvider, + createBrowserRouter, + createRoutesFromElements, +} from "react-router-dom"; import Layout from "./components/Header/Layout"; import { AllCoursesTeacher } from "./components/Courses/AllCoursesTeacher"; import { CourseDetailTeacher } from "./components/Courses/CourseDetailTeacher"; -import { dataLoaderCourseDetail, dataLoaderCourses } from "./components/Courses/CourseUtils"; +import { + dataLoaderCourseDetail, + dataLoaderCourses, +} from "./components/Courses/CourseUtils"; import LanguagePath from "./components/LanguagePath"; import ProjectView from "./pages/project/projectView/ProjectView"; import { ErrorBoundary } from "./pages/error/ErrorBoundary.tsx"; import ProjectCreateHome from "./pages/create_project/ProjectCreateHome.tsx"; import SubmissionsOverview from "./pages/submission_overview/SubmissionsOverview.tsx"; -import {fetchProjectPage} from "./pages/project/FetchProjects.tsx"; +import { fetchProjectPage } from "./utils/fetches/FetchProjects.tsx"; import HomePages from "./pages/home/HomePages.tsx"; import ProjectOverView from "./pages/project/projectOverview.tsx"; import { synchronizeJoinCode } from "./loaders/join-code.ts"; +import { fetchMe } from "./utils/fetches/FetchMe.ts"; const router = createBrowserRouter( createRoutesFromElements( - } errorElement={}> - } loader={fetchProjectPage}/> - }> + } + errorElement={} + loader={fetchMe} + > + } loader={fetchProjectPage} /> + }> } loader={fetchProjectPage} /> - }/> + } + /> - }> - + }> } loader={dataLoaderCourses}/> @@ -30,12 +46,16 @@ const router = createBrowserRouter( } loader={dataLoaderCourseDetail} /> - } loader={fetchProjectPage}/> + } + loader={fetchProjectPage} + /> } /> - - ) + , + ), ); /** diff --git a/frontend/src/Layout.tsx b/frontend/src/Layout.tsx deleted file mode 100644 index 2184d5c7..00000000 --- a/frontend/src/Layout.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Outlet } from "react-router-dom"; -import { Header } from "./components/Header/Header.tsx"; - -/** - * Basic layout component that will be used on all routes. - * @returns The Layout component - */ -export function Layout(): JSX.Element { - return ( - <> -
- - - ); -} \ No newline at end of file diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index b15d188c..6853b745 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -10,22 +10,28 @@ import { Drawer, Grid, ListItemButton, - ListItemText + ListItemText, } from "@mui/material"; +import { useTranslation } from "react-i18next"; import MenuIcon from "@mui/icons-material/Menu"; -import { useTranslation } from 'react-i18next'; -import { useEffect, useState } from "react"; +import React, { useState } from "react"; import LanguageIcon from "@mui/icons-material/Language"; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import { Link } from "react-router-dom"; import { TitlePortal } from "./TitlePortal"; -import {LoginButton} from "./Login"; +import { Me } from "../../types/me.ts"; +import {LoginButton} from "./Login.tsx"; +interface HeaderProps { + me: Me; +} /** * The header component for the application that will be rendered at the top of the page. * @returns - The header component */ -export function Header(): JSX.Element { - const { t, i18n } = useTranslation('translation', { keyPrefix: 'header' }); +export function Header({ me }: HeaderProps): JSX.Element { + const API_URL = import.meta.env.VITE_APP_API_HOST; + const { t, i18n } = useTranslation("translation", { keyPrefix: "header" }); const [languageMenuAnchor, setLanguageMenuAnchor] = useState(null); @@ -44,32 +50,72 @@ export function Header(): JSX.Element { const [open, setOpen] = useState(false); const [listItems, setListItems] = useState([ - { link: "/", text: t("homepage") } + { link: "/", text: t("homepage") }, ]); - useEffect(() => { + const [anchorEl, setAnchorEl] = React.useState( + null, + ); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + React.useEffect(() => { const baseItems = [{ link: "/", text: t("homepage") }]; const additionalItems = [ { link: "/projects", text: t("myProjects") }, - { link: "/courses", text: t("myCourses") } + { link: "/courses", text: t("myCourses") }, ]; - if (isLoggedIn()) { + if (me.loggedIn) { setListItems([...baseItems, ...additionalItems]); - } - else { + } else { setListItems(baseItems); } - }, [t]); + }, [me, t]); return ( - setOpen(!open)} sx={{ color: "white", marginLeft: 0 }}> - + setOpen(!open)} + sx={{ color: "white", marginLeft: 0 }} + > + - - + + {!me.loggedIn && ( + + )} + {me.loggedIn && ( + <> + + + + {me.display_name} + + + setAnchorEl(null)} + > + + {me.display_name} + + + + {t("logout")} + + + + )}
@@ -92,16 +138,14 @@ export function Header(): JSX.Element {
- setOpen(false)} listItems={listItems}/> + setOpen(false)} + listItems={listItems} + />
); } -/** - * @returns Whether a user is logged in or not. - */ -function isLoggedIn() { - return true; -} /** * Renders the drawer menu component. @@ -110,26 +154,46 @@ function isLoggedIn() { * @param listItems - Array of objects representing the list items in the drawer menu. * @returns The Side Bar */ -function DrawerMenu({ open, onClose, listItems }: { open: boolean, onClose: () => void, listItems: { link: string, text: string }[] }) { - +function DrawerMenu({ + open, + onClose, + listItems, +}: { + open: boolean; + onClose: () => void; + listItems: { link: string; text: string }[]; +}) { return ( - + - - + + {listItems.map((listItem, index) => ( - - + + ))} diff --git a/frontend/src/components/Header/Layout.tsx b/frontend/src/components/Header/Layout.tsx index e63283c8..b7852b91 100644 --- a/frontend/src/components/Header/Layout.tsx +++ b/frontend/src/components/Header/Layout.tsx @@ -1,15 +1,18 @@ -import { Outlet } from "react-router-dom"; +import { Outlet, useLoaderData } from "react-router-dom"; import { Header } from "./Header.tsx"; +import { Me } from "../../types/me.ts"; /** * Basic layout component that will be used on all routes. * @returns The Layout component */ export default function Layout(): JSX.Element { + const meData: Me = useLoaderData() as Me; + return ( <> -
+
); -} \ No newline at end of file +} diff --git a/frontend/src/components/Header/Login.tsx b/frontend/src/components/Header/Login.tsx index 496273c3..82548735 100644 --- a/frontend/src/components/Header/Login.tsx +++ b/frontend/src/components/Header/Login.tsx @@ -1,5 +1,6 @@ -import {Button} from "@mui/material"; -import { Link } from 'react-router-dom'; +import { Button } from "@mui/material"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; const CLIENT_ID = import.meta.env.VITE_APP_CLIENT_ID; const REDIRECT_URI = encodeURIComponent(import.meta.env.VITE_APP_API_HOST + "/auth"); @@ -11,6 +12,11 @@ const TENANT_ID = import.meta.env.VITE_APP_TENANT_ID; */ export function LoginButton(): JSX.Element { const link = `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize?prompt=select_account&response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=.default`; + const { t } = useTranslation("translation", { keyPrefix: "home" }); - return + return ( + + ); } diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 54de654d..953060ff 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -1,50 +1,52 @@ import { useTranslation } from "react-i18next"; -import { Button, Container, Typography, Box } from "@mui/material"; -import {Link } from "react-router-dom"; +import { Container, Typography, Box } from "@mui/material"; +import { LoginButton } from "../../components/Header/Login.tsx"; /** * This component is the home page component that will be rendered when on the index route. * @returns - The home page component */ export default function Home() { - const { t } = useTranslation('translation', { keyPrefix: 'home' }); - const login_redirect:string =import.meta.env.VITE_LOGIN_LINK + const { t } = useTranslation("translation", { keyPrefix: "home" }); return ( - + - + Peristerónas - - {t('welcomeDescription', 'Welcome to Peristeronas.')} + + {t("welcomeDescription", "Welcome to Peristeronas.")} - + - ); + + ); } diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index 0fbb9c45..e8df53ed 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -1,21 +1,31 @@ import { useTranslation } from "react-i18next"; -import {Card, CardContent, Typography, Grid, Container, Badge} from '@mui/material'; -import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; -import {DayCalendarSkeleton, LocalizationProvider} from '@mui/x-date-pickers'; -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import React, {useState} from 'react'; -import dayjs, {Dayjs} from "dayjs"; -import { PickersDay, PickersDayProps } from '@mui/x-date-pickers/PickersDay'; -import {ProjectDeadlineCard} from "../project/projectDeadline/ProjectDeadlineCard.tsx"; -import {ProjectDeadline} from "../project/projectDeadline/ProjectDeadline.tsx"; -import {useLoaderData} from "react-router-dom"; +import { + Card, + CardContent, + Typography, + Grid, + Container, + Badge, +} from "@mui/material"; +import { DateCalendar } from "@mui/x-date-pickers/DateCalendar"; +import { DayCalendarSkeleton, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import React, { useState } from "react"; +import dayjs, { Dayjs } from "dayjs"; +import { PickersDay, PickersDayProps } from "@mui/x-date-pickers/PickersDay"; +import { ProjectDeadlineCard } from "../project/projectDeadline/ProjectDeadlineCard.tsx"; +import { ProjectDeadline } from "../project/projectDeadline/ProjectDeadline.tsx"; +import { useLoaderData } from "react-router-dom"; +import { Me } from "../../types/me.ts"; interface DeadlineInfoProps { selectedDay: Dayjs; deadlines: ProjectDeadline[]; } -type ExtendedPickersDayProps = PickersDayProps & { highlightedDays?: number[] }; +type ExtendedPickersDayProps = PickersDayProps & { + highlightedDays?: number[]; +}; /** * Displays the deadlines on a given day @@ -23,23 +33,27 @@ type ExtendedPickersDayProps = PickersDayProps & { highlightedDays?: numb * @param deadlines - All the deadlines to consider * @returns Element */ -const DeadlineInfo: React.FC = ({ selectedDay, deadlines }) => { - const { t } = useTranslation('translation', { keyPrefix: 'student' }); +const DeadlineInfo: React.FC = ({ + selectedDay, + deadlines, +}) => { + const { t } = useTranslation("translation", { keyPrefix: "student" }); const deadlinesOnSelectedDay = deadlines.filter( - project => (project.deadline && dayjs(project.deadline).isSame(selectedDay, 'day')) + (project) => + project.deadline && dayjs(project.deadline).isSame(selectedDay, "day"), ); //list of the corresponding assignment return (
{deadlinesOnSelectedDay.length === 0 ? ( - + - - {t('noDeadline')} - + {t("noDeadline")} - ) : } + ) : ( + + )}
); }; @@ -49,47 +63,54 @@ const DeadlineInfo: React.FC = ({ selectedDay, deadlines }) = * @param props - The day and the deadlines * @returns - The ServerDay component that displays a badge for specific days */ -function ServerDay(props: PickersDayProps & { highlightedDays?: number[] }) { +function ServerDay( + props: PickersDayProps & { highlightedDays?: number[] }, +) { const { highlightedDays = [], day, outsideCurrentMonth, ...other } = props; const isSelected = - !props.outsideCurrentMonth && highlightedDays.indexOf(props.day.date()) >= 0; + !props.outsideCurrentMonth && + highlightedDays.indexOf(props.day.date()) >= 0; return ( - + ); } -const handleMonthChange =( +const handleMonthChange = ( date: Dayjs, - projects:ProjectDeadline[], + projects: ProjectDeadline[], setHighlightedDays: React.Dispatch>, ) => { - setHighlightedDays([]); // projects are now only fetched on page load - const hDays:number[] = [] - projects.map((project, ) => { - if(project.deadline && project.deadline.getMonth() == date.month() && project.deadline.getFullYear() == date.year()){ - hDays.push(project.deadline.getDate()) + const hDays: number[] = []; + projects.map((project) => { + if ( + project.deadline && + project.deadline.getMonth() == date.month() && + project.deadline.getFullYear() == date.year() + ) { + hDays.push(project.deadline.getDate()); } - - } - ); - setHighlightedDays(hDays) - + }); + setHighlightedDays(hDays); }; /** @@ -97,49 +118,45 @@ const handleMonthChange =( * @returns - The home page component */ export default function HomePage() { - const { t } = useTranslation('translation', { keyPrefix: 'student' }); + const { t } = useTranslation("translation", { keyPrefix: "student" }); const [highlightedDays, setHighlightedDays] = React.useState([]); const [selectedDay, setSelectedDay] = useState(dayjs(Date.now())); const loader = useLoaderData() as { - projects: ProjectDeadline[], - me: string - } - const projects = loader.projects + projects: ProjectDeadline[]; + me: Me; + }; + const projects = loader.projects; // Update selectedDay state when a day is selected const handleDaySelect = (day: Dayjs) => { setSelectedDay(day); }; const futureProjects = projects - .filter((p) => (p.deadline && dayjs(dayjs()).isBefore(p.deadline))) + .filter((p) => p.deadline && dayjs(dayjs()).isBefore(p.deadline)) .sort((a, b) => dayjs(a.deadline).diff(dayjs(b.deadline))) - .slice(0, 3) // only show the first 3 + .slice(0, 3); // only show the first 3 const pastDeadlines = projects - .filter((p) => p.deadline && (dayjs()).isAfter(p.deadline)) + .filter((p) => p.deadline && dayjs().isAfter(p.deadline)) .sort((a, b) => dayjs(b.deadline).diff(dayjs(a.deadline))) - .slice(0, 3) // only show the first 3 - const noDeadlineProject = projects.filter((p) => p.deadline === undefined) + .slice(0, 3); // only show the first 3 + const noDeadlineProject = projects.filter((p) => p.deadline === undefined); return ( - + - - {t('myProjects')} - - {futureProjects.length + noDeadlineProject.length > 0? ( + {t("myProjects")} + {futureProjects.length + noDeadlineProject.length > 0 ? ( <> - + ) : ( - - {t('no_projects')} - + {t("no_projects")} )} @@ -147,17 +164,12 @@ export default function HomePage() { - - - {t('deadlines')} - + {t("deadlines")} {pastDeadlines.length > 0 ? ( ) : ( - - {t('no_projects')} - + {t("no_projects")} )} @@ -168,7 +180,9 @@ export default function HomePage() { { handleMonthChange(date, projects, setHighlightedDays) }} + onMonthChange={(date: Dayjs) => { + handleMonthChange(date, projects, setHighlightedDays); + }} onChange={handleDaySelect} renderLoading={() => } slots={{ @@ -183,14 +197,12 @@ export default function HomePage() { - {t('deadlinesOnDay')} {selectedDay.format('MMMM D, YYYY')} + {t("deadlinesOnDay")} {selectedDay.format("MMMM D, YYYY")} - - ); diff --git a/frontend/src/pages/home/HomePages.tsx b/frontend/src/pages/home/HomePages.tsx index 140be48c..60d26c68 100644 --- a/frontend/src/pages/home/HomePages.tsx +++ b/frontend/src/pages/home/HomePages.tsx @@ -2,6 +2,7 @@ import HomePage from './HomePage.tsx'; import Home from "./Home.tsx"; import {useLoaderData} from "react-router-dom"; import {ProjectDeadline} from "../project/projectDeadline/ProjectDeadline.tsx"; +import {Me} from "../../types/me.ts" /** * Gives the requested home page based on the login status @@ -9,11 +10,10 @@ import {ProjectDeadline} from "../project/projectDeadline/ProjectDeadline.tsx"; */ export default function HomePages() { const loader = useLoaderData() as { - projects: ProjectDeadline[], - me: string - } - const me = loader.me - if (me === 'UNKNOWN') { + projects: ProjectDeadline[]; + me: Me; + }; + if (!loader.me.loggedIn) { return ; } else { return ; diff --git a/frontend/src/types/me.ts b/frontend/src/types/me.ts new file mode 100644 index 00000000..6788d7b4 --- /dev/null +++ b/frontend/src/types/me.ts @@ -0,0 +1,6 @@ +export interface Me { + role: string; + display_name: string; + uid: string; + loggedIn: boolean; +} diff --git a/frontend/src/utils/fetches/FetchMe.ts b/frontend/src/utils/fetches/FetchMe.ts new file mode 100644 index 00000000..ff7ab13d --- /dev/null +++ b/frontend/src/utils/fetches/FetchMe.ts @@ -0,0 +1,19 @@ +import {authenticatedFetch} from "../authenticated-fetch.ts"; + +export const fetchMe = async () => { + const API_URL = import.meta.env.VITE_APP_API_HOST; + try { + const response = await authenticatedFetch(`${API_URL}/me`, { + credentials: "include", + }); + if (response.status == 200) { + const data = await response.json(); + data.data.loggedIn = true + return data.data; + } else { + return {loggedIn: false }; + } + } catch (e) { + return { loggedIn: false }; + } +}; diff --git a/frontend/src/pages/project/FetchProjects.tsx b/frontend/src/utils/fetches/FetchProjects.tsx similarity index 88% rename from frontend/src/pages/project/FetchProjects.tsx rename to frontend/src/utils/fetches/FetchProjects.tsx index dd4ba700..64bbbad0 100644 --- a/frontend/src/pages/project/FetchProjects.tsx +++ b/frontend/src/utils/fetches/FetchProjects.tsx @@ -1,9 +1,10 @@ -import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; +import { fetchMe } from "./FetchMe.ts"; +import { authenticatedFetch } from "../authenticated-fetch.ts"; import { Project, ProjectDeadline, ShortSubmission, -} from "./projectDeadline/ProjectDeadline.tsx"; +} from "../../pages/project/projectDeadline/ProjectDeadline.tsx"; const API_URL = import.meta.env.VITE_APP_API_HOST; export const fetchProjectPage = async () => { @@ -12,19 +13,6 @@ export const fetchProjectPage = async () => { return { projects, me }; }; -export const fetchMe = async () => { - try { - const response = await authenticatedFetch(`${API_URL}/me`); - if (response.status == 200) { - const data = await response.json(); - return data.role; - } else { - return "UNKNOWN"; - } - } catch (e) { - return "UNKNOWN"; - } -}; export const fetchProjects = async () => { try { const response = await authenticatedFetch(`${API_URL}/projects`); @@ -50,7 +38,7 @@ export const fetchProjects = async () => { })) .sort( (a: ShortSubmission, b: ShortSubmission) => - b.submission_time.getTime() - a.submission_time.getTime() + b.submission_time.getTime() - a.submission_time.getTime(), )[0]; // fetch the course id of the project const project_item = await ( @@ -82,7 +70,7 @@ export const fetchProjects = async () => { deadline_description: d[0], course_id: Number(project_item.data.course_id), visible_for_students: Boolean( - project_item.data.visible_for_students + project_item.data.visible_for_students, ), archived: Boolean(project_item.data.archived), test_path: project_item.data.test_path, @@ -104,7 +92,7 @@ export const fetchProjects = async () => { deadline_description: undefined, course_id: Number(project_item.data.course_id), visible_for_students: Boolean( - project_item.data.visible_for_students + project_item.data.visible_for_students, ), archived: Boolean(project_item.data.archived), test_path: project_item.data.test_path, @@ -117,7 +105,7 @@ export const fetchProjects = async () => { } catch (e) { return []; } - }) + }), ); formattedData = formattedData.flat(); return formattedData; From 736151439431bde5c96d982ff6ce181a92033431 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sat, 27 Apr 2024 10:48:59 +0200 Subject: [PATCH 291/377] add label (#258) --- .../Courses/CourseDetailTeacher.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index cd729766..a77fbb9d 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -1,4 +1,21 @@ -import { Box, Button, Card, CardActions, CardContent, CardHeader, Checkbox, FormControlLabel, Grid, IconButton, Input, Menu, MenuItem, Paper, Typography } from "@mui/material"; +import { + Box, + Button, + Card, + CardActions, + CardContent, + CardHeader, + Checkbox, + FormControlLabel, + Grid, + IconButton, + Input, + InputLabel, + Menu, + MenuItem, + Paper, + Typography +} from "@mui/material"; import { ChangeEvent, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost } from "./CourseUtils"; @@ -377,6 +394,7 @@ function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, o ))} + {t('expiryDate')}: Date: Sat, 27 Apr 2024 10:58:25 +0200 Subject: [PATCH 292/377] fix logout on server (#264) --- frontend/src/components/Header/Header.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 6853b745..dd809c6e 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -109,9 +109,8 @@ export function Header({ me }: HeaderProps): JSX.Element { {me.display_name} - - - {t("logout")} + + {t("logout")}
From e7098e148db4dfd9251e46be464c7f9881dbf357 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 27 Apr 2024 13:29:56 +0200 Subject: [PATCH 293/377] Fix #277 (#278) --- frontend/src/pages/project/projectView/ProjectView.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 80335194..d3d9a491 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -21,6 +21,7 @@ const API_URL = import.meta.env.VITE_API_HOST; interface Project { title: string; description: string; + regex_expressions: string[]; } /** @@ -100,6 +101,7 @@ export default function ProjectView() { From 49eb8336cc5e488c94ca9c960e67f01412aa4cd7 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 27 Apr 2024 16:49:36 +0200 Subject: [PATCH 294/377] Fix #276 (#281) --- backend/project/endpoints/projects/project_detail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_detail.py b/backend/project/endpoints/projects/project_detail.py index d2affa57..eb7724df 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -22,7 +22,7 @@ API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") -UPLOAD_FOLDER = os.getenv('UPLOAD_URL') +UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER') class ProjectDetail(Resource): From eb993d31b2824d20c9afe8b54bf064a35797a224 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Sat, 27 Apr 2024 20:04:11 +0200 Subject: [PATCH 295/377] Changed AUTHENTICATION_URL .env variable (#268) * changed authentication_url to test_authentication_url * line too long --- backend/project/endpoints/authentication/auth.py | 6 ++++-- backend/tests.yaml | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py index 9d078139..29738b49 100644 --- a/backend/project/endpoints/authentication/auth.py +++ b/backend/project/endpoints/authentication/auth.py @@ -18,7 +18,7 @@ load_dotenv() API_URL = getenv("API_HOST") AUTH_METHOD = getenv("AUTH_METHOD") -AUTHENTICATION_URL = getenv("AUTHENTICATION_URL") +TEST_AUTHENTICATION_URL = getenv("TEST_AUTHENTICATION_URL") CLIENT_ID = getenv("CLIENT_ID") CLIENT_SECRET = getenv("CLIENT_SECRET") HOMEPAGE_URL = getenv("HOMEPAGE_URL") @@ -105,7 +105,9 @@ def test_authentication(): code = request.args.get("code") if code is None: return {"message":"Not yet"}, 500 - profile_res = requests.get(AUTHENTICATION_URL, headers={"Authorization":f"{code}"}, timeout=5) + profile_res = requests.get(TEST_AUTHENTICATION_URL, + headers={"Authorization":f"{code}"}, + timeout=5) resp = redirect(HOMEPAGE_URL, code=303) set_access_cookies(resp, create_access_token(identity=profile_res.json()["id"])) return resp diff --git a/backend/tests.yaml b/backend/tests.yaml index f2c2c82e..72e329b4 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -42,7 +42,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_DB: test_database API_HOST: http://api_is_here - AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose + TEST_AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose AUTH_METHOD: test JWT_SECRET_KEY: Test123 UPLOAD_URL: /data/assignments From 8c459d264fe17a3bec6e773e4eac8f9cb54906c1 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 27 Apr 2024 20:27:27 +0200 Subject: [PATCH 296/377] Creates a general code evaluator (#256) * Fix #255 * using apt to install node instead * fixed using join instead of dirname Co-authored-by: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> --------- Co-authored-by: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> --- .../project/utils/submissions/evaluator.py | 6 +++- .../submissions/evaluators/general/Dockerfile | 32 +++++++++++++++++++ .../evaluators/general/entry_point.sh | 3 ++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 backend/project/utils/submissions/evaluators/general/Dockerfile create mode 100644 backend/project/utils/submissions/evaluators/general/entry_point.sh diff --git a/backend/project/utils/submissions/evaluator.py b/backend/project/utils/submissions/evaluator.py index da81aefc..4a772cdd 100644 --- a/backend/project/utils/submissions/evaluator.py +++ b/backend/project/utils/submissions/evaluator.py @@ -14,8 +14,12 @@ from project.db_in import db from project.models.submission import Submission + +EVALUATORS_FOLDER = path.join(path.dirname(__file__), "evaluators") + DOCKER_IMAGE_MAPPER = { - "PYTHON": path.join(path.dirname(__file__), "evaluators", "python"), + "PYTHON": path.join(EVALUATORS_FOLDER, "python"), + "GENERAL": path.join(EVALUATORS_FOLDER, "general") } diff --git a/backend/project/utils/submissions/evaluators/general/Dockerfile b/backend/project/utils/submissions/evaluators/general/Dockerfile new file mode 100644 index 00000000..202d15c0 --- /dev/null +++ b/backend/project/utils/submissions/evaluators/general/Dockerfile @@ -0,0 +1,32 @@ +# Use Ubuntu as the base image +FROM ubuntu:latest + +# Avoiding user interaction with tzdata, etc. +ENV DEBIAN_FRONTEND=noninteractive + +# Update and install basic dependencies +RUN apt-get update && apt-get install -y \ + software-properties-common \ + build-essential \ + curl \ + wget \ + git \ + cmake # Adding CMake for C/C++ project management + +# Install Python +RUN apt-get install -y python3 python3-pip + +# Install Node.js +RUN apt-get install -y nodejs +RUN apt-get install -y npm + +# Install Java +RUN apt-get install -y openjdk-11-jdk + +# Install Ruby +RUN apt-get install -y ruby-full + +# Clean up to reduce the image size +RUN apt-get clean && rm -rf /var/lib/apt/lists/* + +COPY . . \ No newline at end of file diff --git a/backend/project/utils/submissions/evaluators/general/entry_point.sh b/backend/project/utils/submissions/evaluators/general/entry_point.sh new file mode 100644 index 00000000..9cdc7a66 --- /dev/null +++ b/backend/project/utils/submissions/evaluators/general/entry_point.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +bash /tests/run_test.sh From c8cfe7fb2f8d354e82ab45aa6842c1d7c73b1602 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Sat, 27 Apr 2024 22:01:14 +0200 Subject: [PATCH 297/377] Authentication fix for backend tests (#280) * fixed invalid_token_loader and added function to get csrf_cookie * changed name of file to be correct * small fixes * added csrf to all fetches * forgot an import * formatting * formatting * added custom fetch function * removed e * some backend tests fixed hopefully * added display names to test auth server * set propagate exceptions to TRUE * added checks in test auth * csrf test * get csrf token from response * using cookie_jar * return 401 on test auth when necessary * get csrf token out of set-cookie headers * fixed all user tests * correct import this time * actual correct import * added default empty string so login does not crash * fixed courses test * fixed share link test * fix project tests * fixed submission tests * change docker command * removed authentication in no_auth test and correct authentication for project tests * trying parameterized tests * extra fixes * actually correct fixes this time * errors fixed * fixed csrf error when wrong authentication --- backend/project/__init__.py | 1 + .../project/endpoints/authentication/auth.py | 55 +++++-------- backend/project/utils/user.py | 45 ++++++++++- backend/run_tests.sh | 4 +- backend/test_auth_server/__main__.py | 48 ++++++++---- backend/tests/endpoints/conftest.py | 16 ++-- .../tests/endpoints/course/courses_test.py | 77 ++++++++++++------- .../tests/endpoints/course/share_link_test.py | 21 +++-- backend/tests/endpoints/endpoint.py | 41 +++++----- backend/tests/endpoints/project_test.py | 53 +++++++------ backend/tests/endpoints/submissions_test.py | 31 +++++--- backend/tests/endpoints/user_test.py | 52 ++++++++----- backend/tests/utils/auth_login.py | 13 ++++ 13 files changed, 289 insertions(+), 168 deletions(-) create mode 100644 backend/tests/utils/auth_login.py diff --git a/backend/project/__init__.py b/backend/project/__init__.py index 434980df..3c971e08 100644 --- a/backend/project/__init__.py +++ b/backend/project/__init__.py @@ -35,6 +35,7 @@ def create_app(): """ app = Flask(__name__) + app.config['PROPAGATE_EXCEPTIONS'] = True app.config["JWT_COOKIE_SECURE"] = True app.config["JWT_COOKIE_CSRF_PROTECT"] = True app.config["JWT_TOKEN_LOCATION"] = ["cookies"] diff --git a/backend/project/endpoints/authentication/auth.py b/backend/project/endpoints/authentication/auth.py index 29738b49..1a496c8c 100644 --- a/backend/project/endpoints/authentication/auth.py +++ b/backend/project/endpoints/authentication/auth.py @@ -6,11 +6,9 @@ from flask import Blueprint, request, redirect, abort, make_response from flask_jwt_extended import create_access_token, set_access_cookies from flask_restful import Resource, Api -from sqlalchemy.exc import SQLAlchemyError -from project import db - -from project.models.user import User, Role +from project.models.user import Role +from project.utils.user import get_or_make_user auth_bp = Blueprint("auth", __name__) auth_api = Api(auth_bp) @@ -57,37 +55,11 @@ def microsoft_authentication(): timeout=5) except TimeoutError: return {"message":"Request to Microsoft timed out"}, 500 - if not profile_res or profile_res.status_code != 200: + if profile_res is None or profile_res.status_code != 200: abort(make_response(({"message": "An error occured while trying to authenticate your access token"}, 500))) - auth_user_id = profile_res.json()["id"] - try: - user = db.session.get(User, auth_user_id) - except SQLAlchemyError: - db.session.rollback() - abort(make_response(({"message": - "An unexpected database error occured while fetching the user"}, - 500))) - - if not user: - role = Role.STUDENT - if profile_res.json()["jobTitle"] is not None: - role = Role.TEACHER - - # add user if not yet in database - try: - new_user = User(uid=auth_user_id, - role=role, - display_name=profile_res.json()["displayName"]) - db.session.add(new_user) - db.session.commit() - user = new_user - except SQLAlchemyError: - db.session.rollback() - abort(make_response(({"message": - """An unexpected database error occured - while creating the user during authentication"""}, 500))) + user = get_or_make_user(profile_res) resp = redirect(HOMEPAGE_URL, code=303) additional_claims = {"is_teacher":user.role == Role.TEACHER, "is_admin":user.role == Role.ADMIN} @@ -104,12 +76,27 @@ def test_authentication(): """ code = request.args.get("code") if code is None: - return {"message":"Not yet"}, 500 + abort(make_response(({"message": + "No code"}, + 400))) profile_res = requests.get(TEST_AUTHENTICATION_URL, headers={"Authorization":f"{code}"}, timeout=5) + if profile_res is None: + abort(make_response(({"message": + "An error occured while trying to authenticate your access token"}, + 500))) + if profile_res.status_code != 200: + abort(make_response(({"message": + "Something was wrong with your code"}, + 401))) + user = get_or_make_user(profile_res) resp = redirect(HOMEPAGE_URL, code=303) - set_access_cookies(resp, create_access_token(identity=profile_res.json()["id"])) + additional_claims = {"is_teacher":user.role == Role.TEACHER, + "is_admin":user.role == Role.ADMIN} + set_access_cookies(resp, + create_access_token(identity=profile_res.json()["id"], + additional_claims=additional_claims)) return resp diff --git a/backend/project/utils/user.py b/backend/project/utils/user.py index 6b491066..c8b6438b 100644 --- a/backend/project/utils/user.py +++ b/backend/project/utils/user.py @@ -1,8 +1,51 @@ """Utility functions for the user model""" from typing import Tuple +from requests import Response + +from flask import abort, make_response + +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from project.models.user import User + +from project import db +from project.models.user import User, Role + +def get_or_make_user(profile_res: Response) -> User: + """ + Function to create a new User in the database or return + the user associated with the profile_res received from authentication + + Returns either a database error or the User data. + """ + auth_user_id = profile_res.json()["id"] + try: + user = db.session.get(User, auth_user_id) + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": + "An unexpected database error occured while fetching the user"}, + 500))) + + if not user: + role = Role.STUDENT + if profile_res.json()["jobTitle"] is not None: + role = Role.TEACHER + + # add user if not yet in database + try: + new_user = User(uid=auth_user_id, + role=role, + display_name=profile_res.json()["displayName"]) + db.session.add(new_user) + db.session.commit() + user = new_user + except SQLAlchemyError: + db.session.rollback() + abort(make_response(({"message": + """An unexpected database error occured + while creating the user during authentication"""}, 500))) + return user def is_valid_user(session: Session, uid: any) -> Tuple[bool, str]: """Check if a uid is valid diff --git a/backend/run_tests.sh b/backend/run_tests.sh index a35d5cb2..bc971ebb 100755 --- a/backend/run_tests.sh +++ b/backend/run_tests.sh @@ -1,13 +1,13 @@ #!/bin/bash # Run Docker Compose to build and start the services, and capture the exit code from the test runner service -docker-compose -f tests.yaml up --build --exit-code-from test-runner +docker compose -f tests.yaml up --build --exit-code-from test-runner # Store the exit code in a variable exit_code=$? # After the tests are finished, stop and remove the containers -docker-compose -f tests.yaml down +docker compose -f tests.yaml down # Check the exit code to determine whether the tests passed or failed if [ $exit_code -eq 0 ]; then diff --git a/backend/test_auth_server/__main__.py b/backend/test_auth_server/__main__.py index fe981a74..9cd7ae55 100644 --- a/backend/test_auth_server/__main__.py +++ b/backend/test_auth_server/__main__.py @@ -11,74 +11,90 @@ token_dict = { "teacher1":{ "id":"Gunnar", - "jobTitle":"teacher" + "jobTitle":"teacher", + "displayName":"Gunnar Brinckmann" }, "teacher2":{ "id":"Bart", - "jobTitle":"teacher" + "jobTitle":"teacher", + "displayName":"Bart Bart" }, "student1":{ "id":"w_student", - "jobTitle":None + "jobTitle":None, + "displayName":"William Student" }, "student01":{ "id":"student01", - "jobTitle":None + "jobTitle":None, + "displayName":"Student Nul Een" }, "course_admin1":{ "id":"Rien", - "jobTitle":None + "jobTitle":None, + "displayName":"Rien Admien" }, "del_user":{ "id":"del", - "jobTitle":None + "jobTitle":None, + "displayName":"Peter Deleter" }, "ad3_teacher":{ "id":"brinkmann", - "jobTitle0":"teacher" + "jobTitle0":"teacher", + "displayName":"Gunnar Brinckmann" }, "student02":{ "id":"student02", - "jobTitle":None + "jobTitle":None, + "displayName":"Student Nul Twee" }, "admin1":{ "id":"admin_person", - "jobTitle":"admin" + "jobTitle":"admin", + "displayName":"Admin Man" }, # Lowest authorized user to test login requirement "login": { "id": "login", - "jobTitle": None + "jobTitle": None, + "displayName":"Lotte Login" }, # Student authorization access, associated with valid_... "student": { "id": "student", - "jobTitle": None + "jobTitle": None, + "displayName":"Student" }, # Student authorization access, other "student_other": { "id": "student_other", - "jobTitle": None + "jobTitle": None, + "displayName":"Student Other" }, # Teacher authorization access, associated with valid_... "teacher": { "id": "teacher", - "jobTitle": "teacher" + "jobTitle": "teacher", + "displayName":"Gieter Teacher" }, # Teacher authorization access, other "teacher_other": { "id": "teacher_other", - "jobTitle": "teacher" + "jobTitle": "teacher", + "displayName":"Teacher Other" }, # Admin authorization access, associated with valid_... "admin": { "id": "admin", - "jobTitle": "admin" + "jobTitle": "admin", + "displayName":"Admin Man" }, # Admin authorization access, other "admin_other": { "id": "admin_other", - "jobTitle": "admin" + "jobTitle": "admin", + "displayName":"Admin Woman" } } diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 27ff8be4..bcdddc7c 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -9,6 +9,7 @@ from flask.testing import FlaskClient from sqlalchemy.orm import Session +from tests.utils.auth_login import get_csrf_from_login from project.models.user import User,Role from project.models.course import Course from project.models.course_relation import CourseStudent, CourseAdmin @@ -28,12 +29,12 @@ def data_map(course: Course) -> dict[str, Any]: def auth_test(request: FixtureRequest, client: FlaskClient, data_map: dict[str, Any]) -> tuple: """Add concrete test data to auth""" # endpoint, method, token, allowed - endpoint, method, *other = request.param + endpoint, method, token, *other = request.param for k, v in data_map.items(): endpoint = endpoint.replace(k, str(v)) - - return endpoint, getattr(client, method), *other + csrf = get_csrf_from_login(client, token) + return endpoint, getattr(client, method), csrf, *other @@ -47,14 +48,13 @@ def data_field_type_test( for key, value in data_map.items(): endpoint = endpoint.replace(key, str(value)) - for key, value in data.items(): if isinstance(value, list): data[key] = [data_map.get(v,v) for v in value] elif value in data_map.keys(): data[key] = data_map[value] - - return endpoint, getattr(client, method), token, data + csrf = get_csrf_from_login(client, token) + return endpoint, getattr(client, method), csrf, data @@ -66,8 +66,8 @@ def query_parameter_test(request: FixtureRequest, client: FlaskClient, data_map: for key, value in data_map.items(): endpoint = endpoint.replace(key, str(value)) - - return endpoint, getattr(client, method), token, wrong_parameter + csrf = get_csrf_from_login(client, token) + return endpoint, getattr(client, method), csrf, wrong_parameter diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 1c9f15a9..ca3599c5 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -4,6 +4,7 @@ from dataclasses import fields from pytest import mark from flask.testing import FlaskClient +from tests.utils.auth_login import get_csrf_from_login from tests.endpoints.endpoint import ( TestEndpoint, authentication_tests, @@ -20,10 +21,13 @@ class TestCourseEndpoint(TestEndpoint): ### AUTHENTICATION ### # Where is login required authentication_tests = \ - authentication_tests("/courses", ["get", "post"]) + \ - authentication_tests("/courses/@course_id", ["get", "patch", "delete"]) + \ - authentication_tests("/courses/@course_id/students", ["get", "post", "delete"]) + \ - authentication_tests("/courses/@course_id/admins", ["get", "post", "delete"]) + authentication_tests("/courses", ["get", "post"], ["login"], ["0123456789", ""]) + \ + authentication_tests("/courses/@course_id", ["get", "patch", "delete"], + ["login"], ["0123456789", ""]) + \ + authentication_tests("/courses/@course_id/students", ["get", "post", "delete"], + ["login"], ["0123456789", ""]) + \ + authentication_tests("/courses/@course_id/admins", ["get", "post", "delete"], + ["login"], ["0123456789", ""]) @mark.parametrize("auth_test", authentication_tests, indirect=True) def test_authentication(self, auth_test: tuple[str, Any]): @@ -123,43 +127,48 @@ def test_query_parameters(self, query_parameter_test: tuple[str, Any, str, bool] ### COURSES ### def test_get_courses(self, client: FlaskClient, courses: list[Course]): """Test getting all courses""" - response = client.get("/courses", headers = {"Authorization": "student"}) + csrf = get_csrf_from_login(client, "student") + response = client.get("/courses", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 data = [course["name"] for course in response.json["data"]] assert all(course.name in data for course in courses) def test_get_courses_name(self, client: FlaskClient, course: Course): """Test getting courses for a given course name""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses?name={course.name}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 assert response.json["data"][0]["name"] == course.name def test_get_courses_ufora_id(self, client: FlaskClient, course: Course): """Test getting courses for a given ufora_id""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses?ufora_id={course.ufora_id}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 assert response.json["data"][0]["ufora_id"] == course.ufora_id def test_get_courses_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given teacher""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses?teacher={course.teacher}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 assert response.json["data"][0]["teacher"] == course.teacher def test_get_courses_name_ufora_id(self, client: FlaskClient, course: Course): """Test getting courses for a given course name and ufora_id""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses?name={course.name}&ufora_id={course.ufora_id}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 data = response.json["data"][0] @@ -168,9 +177,10 @@ def test_get_courses_name_ufora_id(self, client: FlaskClient, course: Course): def test_get_courses_name_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given course name and teacher""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses?name={course.name}&teacher={course.teacher}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 data = response.json["data"][0] @@ -179,9 +189,10 @@ def test_get_courses_name_teacher(self, client: FlaskClient, course: Course): def test_get_courses_ufora_id_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given ufora_id and teacher""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses?ufora_id={course.ufora_id}&teacher={course.teacher}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 data = response.json["data"][0] @@ -190,9 +201,10 @@ def test_get_courses_ufora_id_teacher(self, client: FlaskClient, course: Course) def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, course: Course): """Test getting courses for a given name, ufora_id and teacher""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses?name={course.name}&ufora_id={course.ufora_id}&teacher={course.teacher}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 data = response.json["data"][0] @@ -202,14 +214,16 @@ def test_get_courses_name_ufora_id_teacher(self, client: FlaskClient, course: Co def test_post_courses(self, client: FlaskClient, teacher: User): """Test posting a course""" - response = client.post("/courses", headers = {"Authorization": "teacher"}, + csrf = get_csrf_from_login(client, "teacher") + response = client.post("/courses", headers = {"X-CSRF-TOKEN":csrf}, json = { "name": "test", "ufora_id": "test" } ) assert response.status_code == 201 - response = client.get("/courses?name=test", headers = {"Authorization": "student"}) + csrf = get_csrf_from_login(client, "student") + response = client.get("/courses?name=test", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 data = response.json["data"][0] assert data["ufora_id"] == "test" @@ -220,9 +234,10 @@ def test_post_courses(self, client: FlaskClient, teacher: User): ### COURSE ### def test_get_course(self, client: FlaskClient, course: Course): """Test getting a course""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses/{course.course_id}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 data = response.json["data"] @@ -232,9 +247,10 @@ def test_get_course(self, client: FlaskClient, course: Course): def test_patch_course(self, client: FlaskClient, course: Course): """Test patching a course""" + csrf = get_csrf_from_login(client, "teacher") response = client.patch( f"/courses/{course.course_id}", - headers = {"Authorization": "teacher"}, + headers = {"X-CSRF-TOKEN":csrf}, json = {"name": "test"} ) assert response.status_code == 200 @@ -242,14 +258,16 @@ def test_patch_course(self, client: FlaskClient, course: Course): def test_delete_course(self, client: FlaskClient, course: Course): """Test deleting a course""" + csrf = get_csrf_from_login(client, "teacher") response = client.delete( f"/courses/{course.course_id}", - headers = {"Authorization": "teacher"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses/{course.course_id}", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 404 @@ -258,9 +276,10 @@ def test_delete_course(self, client: FlaskClient, course: Course): ### COURSE STUDENTS ### def test_get_students(self, client: FlaskClient, api_host: str, course: Course): """Test getting the students fo a course""" + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses/{course.course_id}/students", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 assert response.json["data"][0]["uid"] == f"{api_host}/users/student" @@ -269,9 +288,10 @@ def test_post_students( self, client: FlaskClient, api_host: str, course: Course, student_other: User ): """Test adding students to a course""" + csrf = get_csrf_from_login(client, "teacher") response = client.post( f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, + headers = {"X-CSRF-TOKEN":csrf}, json = { "students": [student_other.uid] } @@ -283,17 +303,19 @@ def test_delete_students( self, client: FlaskClient, course: Course, student: User ): """Test deleting students from a course""" + csrf = get_csrf_from_login(client, "teacher") response = client.delete( f"/courses/{course.course_id}/students", - headers = {"Authorization": "teacher"}, + headers = {"X-CSRF-TOKEN":csrf}, json = { "students": [student.uid] } ) assert response.status_code == 200 + csrf = get_csrf_from_login(client, "student") response = client.get( f"/courses/{course.course_id}/students", - headers = {"Authorization": "student"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 assert response.json["data"] == [] @@ -303,18 +325,20 @@ def test_delete_students( ### COURSE ADMINS ### def test_get_admins(self, client: FlaskClient, api_host: str, course: Course): """Test getting the admins of a course""" + csrf = get_csrf_from_login(client, "teacher") response = client.get( f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 assert response.json["data"][0]["uid"] == f"{api_host}/users/admin" def test_post_admins(self, client: FlaskClient, course: Course, admin_other: User): """Test adding an admin to a course""" + csrf = get_csrf_from_login(client, "teacher") response = client.post( f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, + headers = {"X-CSRF-TOKEN":csrf}, json = { "admin_uid": admin_other.uid } @@ -324,9 +348,10 @@ def test_post_admins(self, client: FlaskClient, course: Course, admin_other: Use def test_delete_admins(self, client: FlaskClient, course: Course, admin: User): """Test deleting an admin from a course""" + csrf = get_csrf_from_login(client, "teacher") response = client.delete( f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"}, + headers = {"X-CSRF-TOKEN":csrf}, json = { "admin_uid": admin.uid } @@ -334,7 +359,7 @@ def test_delete_admins(self, client: FlaskClient, course: Course, admin: User): assert response.status_code == 204 response = client.get( f"/courses/{course.course_id}/admins", - headers = {"Authorization": "teacher"} + headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 200 assert response.json["data"] == [] diff --git a/backend/tests/endpoints/course/share_link_test.py b/backend/tests/endpoints/course/share_link_test.py index 2575cb3e..a01706f0 100644 --- a/backend/tests/endpoints/course/share_link_test.py +++ b/backend/tests/endpoints/course/share_link_test.py @@ -2,6 +2,9 @@ This file contains the tests for the share link endpoints of the course resource. """ +from tests.utils.auth_login import get_csrf_from_login + + class TestCourseShareLinks: """ Class that will respond to the /courses/course_id/students link @@ -11,39 +14,45 @@ class TestCourseShareLinks: def test_get_share_links(self, client, course): """Test whether the share links are accessible""" + csrf = get_csrf_from_login(client, "teacher") response = client.get(f"courses/{course.course_id}/join_codes", - headers={"Authorization":"teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 def test_post_share_links(self, client, course): """Test whether the share links are accessible to post to""" + csrf = get_csrf_from_login(client, "teacher") response = client.post( f"courses/{course.course_id}/join_codes", - json={"for_admins": True}, headers={"Authorization":"teacher"}) + json={"for_admins": True}, headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 201 def test_delete_share_links(self, client, share_code_admin): """Test whether the share links are accessible to delete""" + csrf = get_csrf_from_login(client, "teacher") response = client.delete( f"courses/{share_code_admin.course_id}/join_codes/{share_code_admin.join_code}", - headers={"Authorization":"teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 def test_get_share_links_404(self, client): """Test whether the share links are accessible""" - response = client.get("courses/0/join_codes", headers={"Authorization":"teacher2"}) + csrf = get_csrf_from_login(client, "teacher2") + response = client.get("courses/0/join_codes", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 404 def test_post_share_links_404(self, client): """Test whether the share links are accessible to post to""" + csrf = get_csrf_from_login(client, "teacher2") response = client.post("courses/0/join_codes", json={"for_admins": True}, - headers={"Authorization":"teacher2"}) + headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 404 def test_for_admins_required(self, client, course): """Test whether the for_admins field is required""" + csrf = get_csrf_from_login(client, "teacher") response = client.post(f"courses/{course.course_id}/join_codes", json={}, - headers={"Authorization":"teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 400 diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index b223e8d8..c2b2c796 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -3,15 +3,18 @@ from typing import Any from pytest import param -def authentication_tests(endpoint: str, methods: list[str]) -> list[Any]: +def authentication_tests(endpoint: str, methods: list[str], + allowed_tokens: list[str], disallowed_tokens: list[str]) -> list[Any]: """Transform the format to single authentication tests""" tests = [] - for method in methods: - tests.append(param( - (endpoint, method), - id = f"{endpoint} {method.upper()}" - )) + for token in (allowed_tokens + disallowed_tokens): + allowed: bool = token in allowed_tokens + for method in methods: + tests.append(param( + (endpoint, method, token, allowed), + id = f"{endpoint} {method.upper()} ({token} {'allowed' if allowed else 'disallowed'})" + )) return tests @@ -82,41 +85,35 @@ class TestEndpoint: """Base class for endpoint tests""" def authentication(self, auth_test: tuple[str, Any]): - """Test if the authentication for the given enpoint works""" - - endpoint, method = auth_test - - response = method(endpoint) - assert response.status_code == 401 + """Test if the authentication for the given endpoint works""" - response = method(endpoint, headers = {"Authorization": "0123456789"}) - assert response.status_code == 401 + endpoint, method, csrf, allowed = auth_test - response = method(endpoint, headers = {"Authorization": "login"}) - assert response.status_code != 401 + response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) + assert allowed == (response.status_code != 401) def authorization(self, auth_test: tuple[str, Any, str, bool]): """Test if the authorization for the given endpoint works""" - endpoint, method, token, allowed = auth_test + endpoint, method, csrf, allowed = auth_test - response = method(endpoint, headers = {"Authorization": token}) + response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) assert allowed == (response.status_code != 403) def data_field_type(self, test: tuple[str, Any, str, dict[str, Any]]): """Test if the datatypes are properly checked for data fields""" - endpoint, method, token, data = test + endpoint, method, csrf, data = test - response = method(endpoint, headers = {"Authorization": token}, json = data) + response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}, json = data) assert response.status_code == 400 def query_parameter(self, test: tuple[str, Any, str, bool]): """Test the query parameter""" - endpoint, method, token, wrong_parameter = test + endpoint, method, csrf, wrong_parameter = test - response = method(endpoint, headers = {"Authorization": token}) + response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) assert wrong_parameter == (response.status_code == 400) if not wrong_parameter: diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index c750cd56..0884145a 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -2,6 +2,8 @@ import json +from tests.utils.auth_login import get_csrf_from_login + def test_assignment_download(client, valid_project): """ Method for assignment download @@ -11,39 +13,42 @@ def test_assignment_download(client, valid_project): with open("tests/resources/testzip.zip", "rb") as zip_file: valid_project["assignment_file"] = zip_file # post the project - with client: - response = client.get("/auth?code=teacher") - response = client.post( - "/projects", - data=valid_project, - content_type='multipart/form-data', - ) - assert response.status_code == 201 - project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}/assignment") - # 404 because the file is not found, no assignment.md in zip file - assert response.status_code == 404 + csrf = get_csrf_from_login(client, "teacher") + response = client.post( + "/projects", + headers = {"X-CSRF-TOKEN":csrf}, + data=valid_project, + content_type='multipart/form-data', + ) + assert response.status_code == 201 + project_id = response.json["data"]["project_id"] + response = client.get(f"/projects/{project_id}/assignment", headers = {"X-CSRF-TOKEN":csrf}) + # 404 because the file is not found, no assignment.md in zip file + assert response.status_code == 404 def test_not_found_download(client): """ Test a not present project download """ - response = client.get("/projects") + csrf = get_csrf_from_login(client, "teacher2") + response = client.get("/projects", headers = {"X-CSRF-TOKEN":csrf}) # get an index that doesnt exist - response = client.get("/projects/-1/assignments", headers={"Authorization":"teacher2"}) + response = client.get("/projects/-1/assignments", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 404 def test_projects_home(client): """Test home project endpoint.""" - response = client.get("/projects", headers={"Authorization":"teacher1"}) + csrf = get_csrf_from_login(client, "teacher1") + response = client.get("/projects", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 def test_getting_all_projects(client): """Test getting all projects""" - response = client.get("/projects", headers={"Authorization":"teacher1"}) + csrf = get_csrf_from_login(client, "teacher1") + response = client.get("/projects", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 assert isinstance(response.json['data'], list) @@ -51,38 +56,38 @@ def test_getting_all_projects(client): def test_post_project(client, valid_project): """Test posting a project to the database and testing if it's present""" valid_project["deadlines"] = json.dumps(valid_project["deadlines"]) - + csrf = get_csrf_from_login(client, "teacher") with open("tests/resources/testzip.zip", "rb") as zip_file: valid_project["assignment_file"] = zip_file # post the project response = client.post( "/projects", data=valid_project, - content_type='multipart/form-data', headers={"Authorization":"teacher"} + content_type='multipart/form-data', headers = {"X-CSRF-TOKEN":csrf} ) assert response.status_code == 201 # check if the project with the id is present project_id = response.json["data"]["project_id"] - response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher"}) + response = client.get(f"/projects/{project_id}", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 def test_remove_project(client, valid_project_entry): """Test removing a project to the datab and fetching it, testing if it's not present anymore""" - + csrf = get_csrf_from_login(client, "teacher") project_id = valid_project_entry.project_id - response = client.delete(f"/projects/{project_id}", headers={"Authorization":"teacher"}) + response = client.delete(f"/projects/{project_id}", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 # check if the project isn't present anymore and the delete indeed went through - response = client.get(f"/projects/{project_id}", headers={"Authorization":"teacher"}) + response = client.get(f"/projects/{project_id}", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 404 def test_patch_project(client, valid_project_entry): """Test functionality of the PATCH method for projects""" - + csrf = get_csrf_from_login(client, "teacher") project_id = valid_project_entry.project_id new_title = valid_project_entry.title + "hallo" @@ -90,6 +95,6 @@ def test_patch_project(client, valid_project_entry): response = client.patch(f"/projects/{project_id}", json={ "title": new_title, "archived": new_archived - }, headers={"Authorization":"teacher"}) + }, headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5c429b4d..5e6c9a0e 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -3,6 +3,7 @@ from os import getenv from flask.testing import FlaskClient from sqlalchemy.orm import Session +from tests.utils.auth_login import get_csrf_from_login from project.models.project import Project from project.models.submission import Submission @@ -14,26 +15,30 @@ class TestSubmissionsEndpoint: ### GET SUBMISSIONS ### def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" - response = client.get("/submissions?uid=-20", headers={"Authorization":"teacher"}) + csrf = get_csrf_from_login(client, "teacher") + response = client.get("/submissions?uid=-20", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 400 def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" + csrf = get_csrf_from_login(client, "teacher") response = client.get("/submissions?project_id=123456789", - headers={"Authorization":"teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 400 assert "message" in response.json def test_get_submissions_wrong_project_type(self, client: FlaskClient): """Test getting submissions for a non-existing project of the wrong type""" - response = client.get("/submissions?project_id=zero", headers={"Authorization":"teacher"}) + csrf = get_csrf_from_login(client, "teacher") + response = client.get("/submissions?project_id=zero", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 400 assert "message" in response.json def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): """Test getting the submissions given a specific project""" + csrf = get_csrf_from_login(client, "teacher") response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", - headers={"Authorization":"teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) data = response.json assert response.status_code == 200 assert "message" in data @@ -42,7 +47,8 @@ def test_get_submissions_project(self, client: FlaskClient, valid_submission_ent ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): """Test getting a submission for a non-existing submission id""" - response = client.get("/submissions/0", headers={"Authorization":"ad3_teacher"}) + csrf = get_csrf_from_login(client, "ad3_teacher") + response = client.get("/submissions/0", headers = {"X-CSRF-TOKEN":csrf}) data = response.json assert response.status_code == 404 assert data["message"] == "Submission with id: 0 not found" @@ -53,8 +59,9 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): submission = session.query(Submission).filter_by( uid="student01", project_id=project.project_id ).first() + csrf = get_csrf_from_login(client, "ad3_teacher") response = client.get(f"/submissions/{submission.submission_id}", - headers={"Authorization":"ad3_teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) data = response.json assert response.status_code == 200 assert data["message"] == "Successfully fetched the submission" @@ -70,8 +77,9 @@ def test_get_submission_correct(self, client: FlaskClient, session: Session): ### PATCH SUBMISSION ### def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): """Test patching a submission for a non-existing submission id""" + csrf = get_csrf_from_login(client, "ad3_teacher") response = client.patch("/submissions/0", data={"grading": 20}, - headers={"Authorization":"ad3_teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) data = response.json assert response.status_code == 404 assert data["message"] == "Submission with id: 0 not found" @@ -82,9 +90,10 @@ def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Sess submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() + csrf = get_csrf_from_login(client, "ad3_teacher") response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 100}, - headers={"Authorization":"ad3_teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (grading=0-20)" @@ -95,9 +104,10 @@ def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() + csrf = get_csrf_from_login(client, "ad3_teacher") response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": "zero"}, - headers={"Authorization":"ad3_teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) data = response.json assert response.status_code == 400 assert data["message"] == "Invalid grading (not a valid float)" @@ -108,9 +118,10 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se submission = session.query(Submission).filter_by( uid="student02", project_id=project.project_id ).first() + csrf = get_csrf_from_login(client, "ad3_teacher") response = client.patch(f"/submissions/{submission.submission_id}", data={"grading": 20}, - headers={"Authorization":"ad3_teacher"}) + headers = {"X-CSRF-TOKEN":csrf}) data = response.json assert response.status_code == 200 assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" diff --git a/backend/tests/endpoints/user_test.py b/backend/tests/endpoints/user_test.py index 7e7abf9f..70e5390f 100644 --- a/backend/tests/endpoints/user_test.py +++ b/backend/tests/endpoints/user_test.py @@ -14,6 +14,7 @@ from project.models.user import User,Role from project.db_in import db from tests import db_url +from tests.utils.auth_login import get_csrf_from_login engine = create_engine(db_url) Session = sessionmaker(bind=engine) @@ -45,32 +46,35 @@ class TestUserEndpoint: def test_delete_user(self, client, valid_user_entry): """Test deleting a user.""" # Delete the user + csrf = get_csrf_from_login(client, "student1") response = client.delete(f"/users/{valid_user_entry.uid}", - headers={"Authorization":"student1"}) + headers={"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 + csrf = get_csrf_from_login(client, "teacher1") # If student 1 sends this request, he would get added again get_response = client.get(f"/users/{valid_user_entry.uid}", - headers={"Authorization":"teacher1"}) + headers={"X-CSRF-TOKEN":csrf}) assert get_response.status_code == 404 def test_delete_user_not_yourself(self, client, valid_user_entry): """Test deleting a user that is not the user the authentication belongs to.""" # Delete the user + csrf = get_csrf_from_login(client, "teacher1") response = client.delete(f"/users/{valid_user_entry.uid}", - headers={"Authorization":"teacher1"}) + headers={"X-CSRF-TOKEN":csrf}) assert response.status_code == 403 - # If student 1 sends this request, he would get added again get_response = client.get(f"/users/{valid_user_entry.uid}", - headers={"Authorization":"teacher1"}) + headers={"X-CSRF-TOKEN":csrf}) assert get_response.status_code == 200 def test_delete_not_present(self, client): """Test deleting a user that does not exist.""" - response = client.delete("/users/-20", headers={"Authorization":"student1"}) + csrf = get_csrf_from_login(client, "teacher1") + response = client.delete("/users/-20", headers={"X-CSRF-TOKEN":csrf}) assert response.status_code == 403 # User does not exist, so you are not the user def test_post_no_authentication(self, client, user_invalid_field): @@ -80,19 +84,22 @@ def test_post_no_authentication(self, client, user_invalid_field): def test_post_authenticated(self, client, valid_user): """Test posting with wrong authentication.""" + csrf = get_csrf_from_login(client, "teacher1") response = client.post("/users", data=valid_user, - headers={"Authorization":"teacher1"}) + headers={"X-CSRF-TOKEN":csrf}) assert response.status_code == 403 # POST to /users is not allowed def test_wrong_form_post(self, client, user_invalid_field): """Test posting with a wrong form.""" + csrf = get_csrf_from_login(client, "teacher1") response = client.post("/users", data=user_invalid_field, - headers={"Authorization":"teacher1"}) + headers={"X-CSRF-TOKEN":csrf}) assert response.status_code == 403 def test_get_all_users(self, client, valid_user_entries): """Test getting all users.""" - response = client.get("/users", headers={"Authorization":"teacher1"}) + csrf = get_csrf_from_login(client, "teacher1") + response = client.get("/users", headers={"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 # Check that the response is a list (even if it's empty) assert isinstance(response.json["data"], list) @@ -106,12 +113,14 @@ def test_get_all_users_no_authentication(self, client): def test_get_all_users_wrong_authentication(self, client): """Test getting all users with wrong authentication.""" - response = client.get("/users", headers={"Authorization":"wrong"}) + client.get("/auth?code=wrong") + response = client.get("/users") assert response.status_code == 401 def test_get_one_user(self, client, valid_user_entry): """Test getting a single user.""" - response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"teacher1"}) + client.get("/auth?code=teacher1") + response = client.get(f"users/{valid_user_entry.uid}") assert response.status_code == 200 assert "data" in response.json @@ -122,7 +131,9 @@ def test_get_one_user_no_authentication(self, client, valid_user_entry): def test_get_one_user_wrong_authentication(self, client, valid_user_entry): """Test getting a single user with wrong authentication.""" - response = client.get(f"users/{valid_user_entry.uid}", headers={"Authorization":"wrong"}) + res = client.get("/auth?code=wrong") + assert res.status_code == 401 + response = client.get(f"users/{valid_user_entry.uid}") assert response.status_code == 401 def test_patch_user_not_authorized(self, client, admin, valid_user_entry): @@ -135,9 +146,10 @@ def test_patch_user_not_authorized(self, client, admin, valid_user_entry): else: new_role = Role.TEACHER new_role = new_role.name + csrf = get_csrf_from_login(client, "student01") response = client.patch(f"/users/{valid_user_entry.uid}", json={ 'role': new_role - }, headers={"Authorization":"student01"}) + }, headers={'X-CSRF-TOKEN':csrf}) assert response.status_code == 403 # Patching a user is not allowed as a not-admin def test_patch_user(self, client, admin, valid_user_entry): @@ -150,16 +162,18 @@ def test_patch_user(self, client, admin, valid_user_entry): else: new_role = Role.TEACHER new_role = new_role.name + csrf = get_csrf_from_login(client, "admin") response = client.patch(f"/users/{valid_user_entry.uid}", json={ 'role': new_role - }, headers={"Authorization":"admin"}) + }, headers={'X-CSRF-TOKEN':csrf}) assert response.status_code == 200 def test_patch_non_existent(self, client, admin): """Test updating a non-existent user.""" + csrf = get_csrf_from_login(client, "admin") response = client.patch("/users/-20", json={ 'role': Role.TEACHER.name - }, headers={"Authorization":"admin"}) + }, headers={'X-CSRF-TOKEN':csrf}) assert response.status_code == 404 def test_patch_non_json(self, client, admin, valid_user_entry): @@ -169,16 +183,16 @@ def test_patch_non_json(self, client, admin, valid_user_entry): valid_user_form["role"] = Role.STUDENT.name else: valid_user_form["role"] = Role.TEACHER.name - + csrf = get_csrf_from_login(client, "admin") response = client.patch(f"/users/{valid_user_form['uid']}", data=valid_user_form, - headers={"Authorization":"admin"}) + headers={'X-CSRF-TOKEN':csrf}) assert response.status_code == 415 def test_get_users_with_query(self, client, valid_user_entries): """Test getting users with a query.""" + get_csrf_from_login(client, "admin") # Send a GET request with query parameters, this is a nonsense entry but good for testing - response = client.get("/users?role=ADMIN", - headers={"Authorization":"teacher1"}) + response = client.get("/users?role=ADMIN") assert response.status_code == 200 # Check that the response contains only the user that matches the query diff --git a/backend/tests/utils/auth_login.py b/backend/tests/utils/auth_login.py new file mode 100644 index 00000000..90f3d3d8 --- /dev/null +++ b/backend/tests/utils/auth_login.py @@ -0,0 +1,13 @@ +"""A file for utility functions to help handling the authentication in tests""" + +def get_csrf_from_login(client, code): + """Log the user in, adding the access token cookie and + return the csrf token cookie to be used in the requests + """ + response = client.get(f"/auth?code={code}") + csrf = next((cookie for cookie + in response.headers.getlist('Set-Cookie') + if 'csrf_access_token' in cookie), "") + if csrf != "": + csrf = csrf.split(";")[0].split("=")[1] + return csrf From eb6767cb2dce5d7ce6fe2c0a65a1a7234948303f Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sat, 27 Apr 2024 22:01:58 +0200 Subject: [PATCH 298/377] Fix fill in the course selector (#261) * fixed the issue * make let const * refactor api url * refactor api url * use effect * use effect * renamed for clarity --- frontend/src/App.tsx | 3 +- frontend/src/components/Courses/courses.ts | 22 ++++++++++ .../components/ProjectForm/ProjectForm.tsx | 43 +++++++++++-------- .../components/ProjectForm/project-form.ts | 11 +++++ 4 files changed, 59 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/Courses/courses.ts create mode 100644 frontend/src/components/ProjectForm/project-form.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd8f2b7b..00bda305 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,6 +21,7 @@ import HomePages from "./pages/home/HomePages.tsx"; import ProjectOverView from "./pages/project/projectOverview.tsx"; import { synchronizeJoinCode } from "./loaders/join-code.ts"; import { fetchMe } from "./utils/fetches/FetchMe.ts"; +import {fetchProjectForm} from "./components/ProjectForm/project-form.ts"; const router = createBrowserRouter( createRoutesFromElements( @@ -51,7 +52,7 @@ const router = createBrowserRouter( element={} loader={fetchProjectPage} /> - } /> + } loader={fetchProjectForm}/> , diff --git a/frontend/src/components/Courses/courses.ts b/frontend/src/components/Courses/courses.ts new file mode 100644 index 00000000..a532f88d --- /dev/null +++ b/frontend/src/components/Courses/courses.ts @@ -0,0 +1,22 @@ +import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; +const API_URL = import.meta.env.VITE_APP_API_HOST; + +/** + * @param user_uid - the user to fetch the courses from where it is a teacher + * @returns Courses[] + */ +export async function fetchProjectFormCourses(user_uid: string){ + try { + const response = await authenticatedFetch( + `${API_URL}/courses?teacher=${user_uid}`, + ); + const jsonData = await response.json(); + if (jsonData.data) { + return jsonData.data; + } else { + return []; + } + } catch (_) { + return []; + } +} diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index b9410bc5..f1813232 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -22,7 +22,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import DeadlineCalender from "../Calender/DeadlineCalender.tsx"; import { Deadline } from "../../types/deadline"; import {InfoOutlined} from "@mui/icons-material"; -import {Link} from "react-router-dom"; +import {Link, useLoaderData, useLocation} from "react-router-dom"; import FolderDragDrop from "../FolderUpload/FolderUpload.tsx"; import TabPanel from "@mui/lab/TabPanel"; import {TabContext} from "@mui/lab"; @@ -43,8 +43,7 @@ interface RegexData { regex: string; } -const apiUrl = import.meta.env.VITE_API_HOST -const user = "Gunnar" +const API_URL = import.meta.env.VITE_APP_API_HOST /** * @returns Form for uploading project @@ -71,10 +70,6 @@ export default function ProjectForm() { const [assignmentFile, setAssignmentFile] = useState(); const [filename, setFilename] = useState(""); - const [courses, setCourses] = useState([]); - const [courseId, setCourseId] = useState(''); - const [courseName, setCourseName] = useState(''); - const [containsDockerfile, setContainsDockerfile] = useState(false); const [containsRuntest, setContainsRuntest] = useState(false); @@ -83,9 +78,27 @@ export default function ProjectForm() { const [validRunner, setValidRunner] = useState(true); const [validSubmission, setValidSubmission] = useState(true); - useEffect(() => { - fetchCourses(); - }, [regexError]); + const courses = useLoaderData() as Course[] + + const [courseId, setCourseId] = useState(''); + const [courseName, setCourseName] = useState(''); + const location = useLocation(); + + useEffect(() =>{ + const urlParams = new URLSearchParams(location.search); + const initialCourseId = urlParams.get('course_id') || ''; + let initialCourseName = '' + for( const c of courses){ + const parts = c.course_id.split('/'); + const courseId = parts[parts.length - 1]; + if (courseId === initialCourseId){ + initialCourseName = c.name + } + } + setCourseId(initialCourseId) + setCourseName(initialCourseName) + } + ,[courses,location]) const handleRunnerSwitch = (newRunner: string) => { if (newRunner === t('clearSelected')) { @@ -136,14 +149,6 @@ export default function ProjectForm() { } } - const fetchCourses = async () => { - const response = await authenticatedFetch(`${apiUrl}/courses?teacher=${user}`) - const jsonData = await response.json(); - if (jsonData.data) { - setCourses(jsonData.data); - } - } - const appendRegex = (r: string) => { if (r == '' || regexExpressions.some(reg => reg.regex == r)) { setRegexError(true); @@ -201,7 +206,7 @@ export default function ProjectForm() { formData.append("runner", runner); } - const response = await authenticatedFetch(`${apiUrl}/projects`, { + const response = await authenticatedFetch(`${API_URL}/projects`, { method: "post", body: formData, }) diff --git a/frontend/src/components/ProjectForm/project-form.ts b/frontend/src/components/ProjectForm/project-form.ts new file mode 100644 index 00000000..ec282569 --- /dev/null +++ b/frontend/src/components/ProjectForm/project-form.ts @@ -0,0 +1,11 @@ +import { fetchMe } from "../../utils/fetches/FetchMe.ts"; +import {fetchProjectFormCourses} from "../Courses/courses.ts"; + +/** + * Fetches the courses of the current user + * @returns Course[] + */ +export async function fetchProjectForm (){ + const me = await fetchMe(); + return await fetchProjectFormCourses(me.uid); +} \ No newline at end of file From 0d59f86118db24d5766c9707f315e9574612ddf0 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sun, 28 Apr 2024 13:30:03 +0200 Subject: [PATCH 299/377] use date not hours (#289) --- frontend/src/components/Courses/CourseDetailTeacher.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index a77fbb9d..03308c3c 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -397,8 +397,8 @@ function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, o {t('expiryDate')}: From 64018c44bfc038e14593de589c9a41a138716bd1 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 28 Apr 2024 13:30:27 +0200 Subject: [PATCH 300/377] Cleanup of authentication tests (#285) * Cleanup of authentication tests * Adding tests where no csrf token is given --- backend/tests/endpoints/conftest.py | 12 ++++++----- .../tests/endpoints/course/courses_test.py | 13 +++++------- backend/tests/endpoints/endpoint.py | 21 +++++++++++-------- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index bcdddc7c..de82cf72 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -26,15 +26,17 @@ def data_map(course: Course) -> dict[str, Any]: } @fixture -def auth_test(request: FixtureRequest, client: FlaskClient, data_map: dict[str, Any]) -> tuple: +def auth_test( + request: FixtureRequest, client: FlaskClient, data_map: dict[str, Any] + ) -> tuple[str, Any, str, bool]: """Add concrete test data to auth""" - # endpoint, method, token, allowed - endpoint, method, token, *other = request.param + endpoint, method, token, allowed = request.param for k, v in data_map.items(): endpoint = endpoint.replace(k, str(v)) - csrf = get_csrf_from_login(client, token) - return endpoint, getattr(client, method), csrf, *other + csrf = get_csrf_from_login(client, token) if token else None + + return endpoint, getattr(client, method), csrf, allowed diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index ca3599c5..123e89a9 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -21,16 +21,13 @@ class TestCourseEndpoint(TestEndpoint): ### AUTHENTICATION ### # Where is login required authentication_tests = \ - authentication_tests("/courses", ["get", "post"], ["login"], ["0123456789", ""]) + \ - authentication_tests("/courses/@course_id", ["get", "patch", "delete"], - ["login"], ["0123456789", ""]) + \ - authentication_tests("/courses/@course_id/students", ["get", "post", "delete"], - ["login"], ["0123456789", ""]) + \ - authentication_tests("/courses/@course_id/admins", ["get", "post", "delete"], - ["login"], ["0123456789", ""]) + authentication_tests("/courses", ["get", "post"]) + \ + authentication_tests("/courses/@course_id", ["get", "patch", "delete"]) + \ + authentication_tests("/courses/@course_id/students", ["get", "post", "delete"]) + \ + authentication_tests("/courses/@course_id/admins", ["get", "post", "delete"]) @mark.parametrize("auth_test", authentication_tests, indirect=True) - def test_authentication(self, auth_test: tuple[str, Any]): + def test_authentication(self, auth_test: tuple[str, Any, str, bool]): """Test the authentication""" super().authentication(auth_test) diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index c2b2c796..1be6b3be 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -3,17 +3,17 @@ from typing import Any from pytest import param -def authentication_tests(endpoint: str, methods: list[str], - allowed_tokens: list[str], disallowed_tokens: list[str]) -> list[Any]: +def authentication_tests(endpoint: str, methods: list[str]) -> list[Any]: """Transform the format to single authentication tests""" tests = [] - for token in (allowed_tokens + disallowed_tokens): - allowed: bool = token in allowed_tokens - for method in methods: + for method in methods: + for token in [None, "0123456789", "login"]: + allowed = token == "login" tests.append(param( - (endpoint, method, token, allowed), - id = f"{endpoint} {method.upper()} ({token} {'allowed' if allowed else 'disallowed'})" + (endpoint, method, token, allowed), + id = f"{endpoint} {method.upper()} " \ + f"({token} {'allowed' if allowed else 'disallowed'})" )) return tests @@ -84,12 +84,15 @@ def query_parameter_tests( class TestEndpoint: """Base class for endpoint tests""" - def authentication(self, auth_test: tuple[str, Any]): + def authentication(self, auth_test: tuple[str, Any, str, bool]): """Test if the authentication for the given endpoint works""" endpoint, method, csrf, allowed = auth_test - response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) + if csrf: + response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) + else: + response = method(endpoint) assert allowed == (response.status_code != 401) def authorization(self, auth_test: tuple[str, Any, str, bool]): From 1514a5be4acede173c8fb3f4f989704a77c2dbfe Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sun, 28 Apr 2024 20:11:33 +0200 Subject: [PATCH 301/377] Submissions download authorization (#293) * Fixing * Removing a test --- backend/project/endpoints/submissions/submission_download.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/submissions/submission_download.py b/backend/project/endpoints/submissions/submission_download.py index cc0b3c8e..25186c11 100644 --- a/backend/project/endpoints/submissions/submission_download.py +++ b/backend/project/endpoints/submissions/submission_download.py @@ -9,6 +9,8 @@ from flask import Response, stream_with_context from flask_restful import Resource from project.models.submission import Submission +from project.utils.authentication import authorize_submission_request +from project.db_in import db API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") @@ -18,11 +20,12 @@ class SubmissionDownload(Resource): """ Resource to download a submission. """ + @authorize_submission_request def get(self, submission_id: int): """ Download a submission as a zip file. """ - submission = Submission.query.get(submission_id) + submission = db.session.get(Submission, submission_id) if submission is None: return { "message": f"Submission (submission_id={submission_id}) not found", From 28fb56805eac2f626927e7186c7d9bebcb65f032 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:48:56 +0200 Subject: [PATCH 302/377] Courses now converts deadlines from string to Date right after fetch (#295) * adjusted courses frontend to use deadlines Date instantiated right after fetch instead of str str 2d * lintr * removed TODO * deadlines now have their own datatype! --- .../Courses/CourseDetailTeacher.tsx | 11 ++-- .../Courses/CourseUtilComponents.tsx | 50 ++++++++++++----- .../src/components/Courses/CourseUtils.tsx | 56 +++++++++++++++---- 3 files changed, 87 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index 03308c3c..60564260 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -18,7 +18,7 @@ import { } from "@mui/material"; import { ChangeEvent, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Course, Project, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost } from "./CourseUtils"; +import { Course, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost, ProjectDetail } from "./CourseUtils"; import { Link, useNavigate, NavigateFunction, useLoaderData } from "react-router-dom"; import { Title } from "../Header/Title"; import ClearIcon from '@mui/icons-material/Clear'; @@ -101,12 +101,13 @@ export function CourseDetailTeacher(): JSX.Element { setAnchorElStudent(null); }; - const courseDetail = useLoaderData() as { //TODO CATCH ERROR + const courseDetail = useLoaderData() as { course: Course , - projects:Project[] , + projects:ProjectDetail[] , admins: UserUid[], students: UserUid[] }; + const { course, projects, admins, students } = courseDetail; const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); const { i18n } = useTranslation(); @@ -179,7 +180,7 @@ export function CourseDetailTeacher(): JSX.Element { * @param projects - The array of projects. * @returns Either a place holder for no projects or a grid of cards describing the projects. */ -function EmptyOrNotProjects({projects}: {projects: Project[]}): JSX.Element { +function EmptyOrNotProjects({projects}: {projects: ProjectDetail[]}): JSX.Element { const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); if(projects === undefined || projects.length === 0){ return ( @@ -199,7 +200,7 @@ function EmptyOrNotProjects({projects}: {projects: Project[]}): JSX.Element { {getNearestFutureDate(project.deadlines) && ( - {`${t('deadline')}: ${getNearestFutureDate(project.deadlines)?.toLocaleDateString()}`} + {`${t('deadline')}: ${getNearestFutureDate(project.deadlines)?.date.toLocaleDateString()}`} )} diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx index 48d6bdb2..58d01874 100644 --- a/frontend/src/components/Courses/CourseUtilComponents.tsx +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -13,6 +13,7 @@ import { import { Course, Project, + ProjectDetail, apiHost, getIdFromLink, getNearestFutureDate, @@ -100,7 +101,7 @@ export function SideScrollableCourses({ const [teacherNameFilter, setTeacherNameFilter] = useState( initialTeacherNameFilter ); - const [projects, setProjects] = useState<{ [courseId: string]: Project[] }>( + const [projects, setProjects] = useState<{ [courseId: string]: ProjectDetail[] }>( {} ); @@ -159,10 +160,33 @@ export function SideScrollableCourses({ ); const projectResults = await Promise.all(projectPromises); - const projectsMap: { [courseId: string]: Project[] } = {}; + const projectsMap: { [courseId: string]: ProjectDetail[] } = {}; projectResults.forEach((result, index) => { - projectsMap[getIdFromLink(courses[index].course_id)] = result.data; + const detailProjectPromises = result.data.map(async (item: Project) => { + const projectRes = await authenticatedFetch(item.project_id); + if (projectRes.status !== 200) { + throw new Response("Failed to fetch project data", { + status: projectRes.status, + }); + } + const projectJson = await projectRes.json(); + const projectData = projectJson.data; + const project: ProjectDetail = { + ...item, + deadlines: projectData.deadlines.map( + ([description, dateString]: [string, string]) => ({ + description, + date: new Date(dateString), + }) + ), + }; + return project; + }); + Promise.all(detailProjectPromises).then((projects) => { + projectsMap[getIdFromLink(courses[index].course_id)] = projects; + setProjects({ ...projectsMap }); + }); }); setProjects(projectsMap); @@ -216,7 +240,7 @@ function EmptyOrNotFilteredCourses({ projects, }: { filteredCourses: Course[]; - projects: { [courseId: string]: Project[] }; + projects: { [courseId: string]: ProjectDetail[] }; }): JSX.Element { const { t } = useTranslation("translation", { keyPrefix: "courseDetailTeacher", @@ -286,7 +310,7 @@ function EmptyOrNotProjects({ projects, noProjectsText, }: { - projects: Project[]; + projects: ProjectDetail[]; noProjectsText: string; }): JSX.Element { if (projects === undefined || projects.length === 0) { @@ -305,15 +329,15 @@ function EmptyOrNotProjects({ {projects.slice(0, 3).map((project) => { let timeLeft = ""; if (project.deadlines != undefined) { - const deadlineDate = getNearestFutureDate(project.deadlines); - if (deadlineDate == null) { - return <>; - } - const diffTime = Math.abs(deadlineDate.getTime() - now.getTime()); - const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); - const diffDays = Math.ceil(diffHours * 24); + const deadline = getNearestFutureDate(project.deadlines); + if(deadline !== null){ + const deadlineDate = deadline.date; + const diffTime = Math.abs(deadlineDate.getTime() - now.getTime()); + const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); + const diffDays = Math.ceil(diffHours * 24); - timeLeft = diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; + timeLeft = diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; + } } return ( diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 331b5ea5..92c55aab 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -12,7 +12,17 @@ export interface Course { export interface Project { title: string; project_id: string; - deadlines: string[][]; +} + +export interface ProjectDetail { + title: string; + project_id: string; + deadlines: Deadline[]; +} + +interface Deadline { + description: string; + date: Date; } export const apiHost = import.meta.env.VITE_APP_API_HOST; @@ -80,17 +90,15 @@ export function getIdFromLink(link: string): string { } /** - * Function to find the nearest future date from a list of dates - * @param dates - Array of dates - * @returns The nearest future date + * Function to find the nearest future deadline from a list of deadlines + * @param deadlines - List of deadlines + * @returns The nearest future deadline */ -export function getNearestFutureDate(dates: string[][]): Date | null { +export function getNearestFutureDate(deadlines: Deadline[]): Deadline | null { const now = new Date(); - const futureDates = dates - .map((date) => new Date(date[1])) - .filter((date) => date > now); - if (futureDates.length === 0) return null; - return futureDates.reduce((nearest, current) => + const futureDeadlines = deadlines.filter((deadline) => deadline.date > now); + if (futureDeadlines.length === 0) return null; + return futureDeadlines.reduce((nearest, current) => current < nearest ? current : nearest ); } @@ -125,7 +133,31 @@ const dataLoaderCourse = async (courseId: string) => { const dataLoaderProjects = async (courseId: string) => { const params = new URLSearchParams({ course_id: courseId }); - return fetchData(`projects`, params); + const uri = `${apiHost}/projects?${params}`; + + const res = await authenticatedFetch(uri); + if (res.status !== 200) { + throw new Response("Failed to fetch data", { status: res.status }); + } + const jsonResult = await res.json(); + const projects: ProjectDetail[] = jsonResult.data.map(async (item: Project) => { + const projectRes = await authenticatedFetch(item.project_id); + if (projectRes.status !== 200) { + throw new Response("Failed to fetch project data", { status: projectRes.status }); + } + const projectJson = await projectRes.json(); + const projectData = projectJson.data; + const project: ProjectDetail = { + ...item, + deadlines: projectData.deadlines.map((deadline: Deadline) => ({ + description: deadline.description, + date: new Date(deadline.date), + })), + }; + return project; + }); + + return Promise.all(projects); }; const dataLoaderAdmins = async (courseId: string) => { @@ -145,10 +177,10 @@ export const dataLoaderCourseDetail = async ({ if (!courseId) { throw new Error("Course ID is undefined."); } + const course = await dataLoaderCourse(courseId); const projects = await dataLoaderProjects(courseId); const admins = await dataLoaderAdmins(courseId); const students = await dataLoaderStudents(courseId); - return { course, projects, admins, students }; }; From 9ca85cf9371ccd57c49c2ab613a3d9c5abd0f7da Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:49:31 +0200 Subject: [PATCH 303/377] Submission authorization issues (#301) * fix * Fixing linter --- .../submissions/submission_detail.py | 23 ++----------------- .../endpoints/submissions/submissions.py | 9 ++------ backend/project/utils/authentication.py | 5 ++-- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/backend/project/endpoints/submissions/submission_detail.py b/backend/project/endpoints/submissions/submission_detail.py index ad7e2c0a..db9463ef 100644 --- a/backend/project/endpoints/submissions/submission_detail.py +++ b/backend/project/endpoints/submissions/submission_detail.py @@ -9,11 +9,10 @@ from sqlalchemy import exc from project.db_in import db from project.models.submission import Submission -from project.utils.query_agent import delete_by_id_from_model from project.utils.authentication import ( authorize_submission_request, - authorize_grader, - authorize_submission_author) + authorize_grader +) API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") @@ -121,21 +120,3 @@ def patch(self, submission_id:int) -> dict[str, any]: data["message"] = \ f"An error occurred while patching submission (submission_id={submission_id})" return data, 500 - - @authorize_submission_author - def delete(self, submission_id: int) -> dict[str, any]: - """Delete a submission given a submission ID - - Args: - submission_id (int): Submission ID - - Returns: - dict[str, any]: A message - """ - - return delete_by_id_from_model( - Submission, - "submission_id", - submission_id, - BASE_URL - ) diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index c5e9603b..60bd702b 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -18,7 +18,6 @@ from project.models.course import Course from project.models.course_relation import CourseAdmin from project.utils.files import all_files_uploaded -from project.utils.user import is_valid_user from project.utils.project import is_valid_project from project.utils.authentication import authorize_student_submission, login_required_return_uid from project.utils.submissions.evaluator import run_evaluator @@ -99,7 +98,7 @@ def get(self, uid=None) -> dict[str, any]: return data, 500 @authorize_student_submission - def post(self) -> dict[str, any]: + def post(self, uid=None) -> dict[str, any]: """Post a new submission to a project Returns: @@ -114,11 +113,7 @@ def post(self) -> dict[str, any]: submission = Submission() # User - valid, message = is_valid_user(session, request.form.get("uid")) - if not valid: - data["message"] = message - return data, 400 - submission.uid = request.form.get("uid") + submission.uid = uid # Project project_id = request.form.get("project_id") diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index b59ff281..30a79d68 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -241,11 +241,10 @@ def authorize_student_submission(f): @wraps(f) def wrap(*args, **kwargs): auth_user_id = return_authenticated_user_id() + kwargs["uid"] = auth_user_id project_id = request.form["project_id"] course_id = get_course_of_project(project_id) - if (is_student_of_course(auth_user_id, course_id) - and project_visible(project_id) - and auth_user_id == request.form.get("uid")): + if (is_student_of_course(auth_user_id, course_id) and project_visible(project_id)): return f(*args, **kwargs) abort(make_response( ({"message": "You're not authorized to perform this action"}, 403))) From 7a6c9813c159165fe9a38783fb0d2425d38647d8 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Fri, 3 May 2024 14:18:26 +0200 Subject: [PATCH 304/377] Submission issues (#304) * Change the returned data from submission patch * Updating the filtering on /submissions * Return bad request on invalid keys in data * Little fix * Little fix * fixing the f linter --- .../submissions/submission_detail.py | 14 ++++------ .../endpoints/submissions/submissions.py | 28 +++++++++---------- .../project/utils/models/submission_utils.py | 11 ++++++++ backend/tests/endpoints/submissions_test.py | 25 ++++++++++++----- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/backend/project/endpoints/submissions/submission_detail.py b/backend/project/endpoints/submissions/submission_detail.py index db9463ef..81b9c783 100644 --- a/backend/project/endpoints/submissions/submission_detail.py +++ b/backend/project/endpoints/submissions/submission_detail.py @@ -9,6 +9,7 @@ from sqlalchemy import exc from project.db_in import db from project.models.submission import Submission +from project.utils.models.submission_utils import submission_response from project.utils.authentication import ( authorize_submission_request, authorize_grader @@ -87,6 +88,10 @@ def patch(self, submission_id:int) -> dict[str, any]: return data, 404 # Update the grading field + if set(request.form.keys()) - {"grading"}: + data["message"] = "Invalid data field given, only 'grading' is allowed" + return data, 400 + grading = request.form.get("grading") if grading is not None: try: @@ -105,14 +110,7 @@ def patch(self, submission_id:int) -> dict[str, any]: data["message"] = f"Submission (submission_id={submission_id}) patched" data["url"] = urljoin(f"{BASE_URL}/", str(submission.submission_id)) - data["data"] = { - "id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), - "user": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), - "grading": submission.grading, - "time": submission.submission_time, - "status": submission.submission_status - } + data["data"] = submission_response(submission, API_HOST) return data, 200 except exc.SQLAlchemyError: diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index 60bd702b..b80319ab 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -14,7 +14,6 @@ from project.db_in import db from project.models.submission import Submission, SubmissionStatus from project.models.project import Project -from project.models.user import User from project.models.course import Course from project.models.course_relation import CourseAdmin from project.utils.files import all_files_uploaded @@ -22,7 +21,7 @@ from project.utils.authentication import authorize_student_submission, login_required_return_uid from project.utils.submissions.evaluator import run_evaluator from project.utils.models.project_utils import get_course_of_project - +from project.utils.models.submission_utils import submission_response API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") @@ -45,17 +44,21 @@ def get(self, uid=None) -> dict[str, any]: } filters = dict(request.args) try: + invalid_parameters = set(filters.keys()) - {"uid", "project_id"} + if invalid_parameters: + data["message"] = f"Invalid query parameter(s) {invalid_parameters}" + return data, 400 + # Check the uid query parameter user_id = filters.get("uid") - if user_id and not User.query.filter_by(uid=user_id).all(): + if user_id and not isinstance(user_id, str): data["message"] = f"Invalid user (uid={user_id})" return data, 400 # Check the project_id query parameter project_id = filters.get("project_id") if project_id: - if not project_id.isdigit() or \ - not Project.query.filter_by(project_id=project_id).all(): + if not project_id.isdigit(): data["message"] = f"Invalid project (project_id={project_id})" return data, 400 filters["project_id"] = int(project_id) @@ -109,6 +112,10 @@ def post(self, uid=None) -> dict[str, any]: "url": BASE_URL } try: + if set(request.form.keys()) - {"project_id", "files"}: + data["message"] = "Invalid data fields, only 'project_id' and 'files' are allowed" + return data, 400 + with db.session() as session: submission = Submission() @@ -180,15 +187,8 @@ def post(self, uid=None) -> dict[str, any]: data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") - data["data"] = { - "submission_id": urljoin(f"{BASE_URL}/", str(submission.submission_id)), - "uid": urljoin(f"{API_HOST}/", f"/users/{submission.uid}"), - "project_id": urljoin(f"{API_HOST}/", f"/projects/{submission.project_id}"), - "grading": submission.grading, - "submission_time": submission.submission_time, - "submission_status": submission.submission_status - } - return data, 202 + data["data"] = submission_response(submission, API_HOST) + return data, 200 except exc.SQLAlchemyError: session.rollback() diff --git a/backend/project/utils/models/submission_utils.py b/backend/project/utils/models/submission_utils.py index 5cd46a68..8f5f7014 100644 --- a/backend/project/utils/models/submission_utils.py +++ b/backend/project/utils/models/submission_utils.py @@ -31,3 +31,14 @@ def get_course_of_submission(submission_id): """Get the course linked to a given submission""" submission = get_submission(submission_id) return get_course_of_project(submission.project_id) + +def submission_response(submission, api_host): + """Return the response data for a submission""" + return { + "submission_id": f"{api_host}/submissions/{submission.submission_id}", + "uid": f"{api_host}/users/{submission.uid}", + "project_id": f"{api_host}/projects/{submission.project_id}", + "grading": submission.grading, + "submission_time": submission.submission_time, + "submission_status": submission.submission_status + } diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5e6c9a0e..dca6bd83 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -17,14 +17,16 @@ def test_get_submissions_wrong_user(self, client: FlaskClient): """Test getting submissions for a non-existing user""" csrf = get_csrf_from_login(client, "teacher") response = client.get("/submissions?uid=-20", headers = {"X-CSRF-TOKEN":csrf}) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.json["data"] == [] def test_get_submissions_wrong_project(self, client: FlaskClient): """Test getting submissions for a non-existing project""" csrf = get_csrf_from_login(client, "teacher") response = client.get("/submissions?project_id=123456789", headers = {"X-CSRF-TOKEN":csrf}) - assert response.status_code == 400 + assert response.status_code == 200 + assert response.json["data"] == [] assert "message" in response.json def test_get_submissions_wrong_project_type(self, client: FlaskClient): @@ -43,6 +45,15 @@ def test_get_submissions_project(self, client: FlaskClient, valid_submission_ent assert response.status_code == 200 assert "message" in data + def test_get_submission_wrong_parameter(self, client: FlaskClient): + """Test a submission filtering on a non existing parameter""" + response = client.get( + "/submissions?parameter=0", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "teacher")} + ) + assert response.status_code == 400 + + ### GET SUBMISSION ### def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): @@ -127,10 +138,10 @@ def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Se assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" assert data["url"] == f"{API_HOST}/submissions/{submission.submission_id}" assert data["data"] == { - "id": f"{API_HOST}/submissions/{submission.submission_id}", - "user": f"{API_HOST}/users/student02", - "project": f"{API_HOST}/projects/{project.project_id}", + "submission_id": f"{API_HOST}/submissions/{submission.submission_id}", + "uid": f"{API_HOST}/users/student02", + "project_id": f"{API_HOST}/projects/{project.project_id}", "grading": 20, - "time": 'Thu, 14 Mar 2024 23:59:59 GMT', - "status": 'FAIL' + "submission_time": 'Thu, 14 Mar 2024 23:59:59 GMT', + "submission_status": 'FAIL' } From c110c10903272e138837838b17e6cedbbde499d9 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Fri, 3 May 2024 18:56:54 +0200 Subject: [PATCH 305/377] change path (#305) --- frontend/src/App.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 00bda305..a1402c22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,9 +38,6 @@ const router = createBrowserRouter( path="project/:projectId/overview" element={} /> - - }> - } loader={dataLoaderCourses}/> @@ -52,6 +49,7 @@ const router = createBrowserRouter( element={} loader={fetchProjectPage} /> + }> } loader={fetchProjectForm}/> From 3f1cef2587b482e6e0bc9e544453ce145f3c3589 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sat, 4 May 2024 09:55:52 +0200 Subject: [PATCH 306/377] course path (#308) --- frontend/src/pages/project/projectOverview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/project/projectOverview.tsx b/frontend/src/pages/project/projectOverview.tsx index 9de97da1..8f2a5a5c 100644 --- a/frontend/src/pages/project/projectOverview.tsx +++ b/frontend/src/pages/project/projectOverview.tsx @@ -39,7 +39,7 @@ export default function ProjectOverView() { return ( - {courseProjects[0].course.name} {courseProjects[0].course.ufora_id} From 82c2a50dde01bf1aee8c00144ab4968c03659e32 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sat, 4 May 2024 10:39:19 +0200 Subject: [PATCH 307/377] Navbar link to homepage added (#262) * navbar link added * rm i18 --- frontend/src/components/Header/Header.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index dd809c6e..17913190 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -85,6 +85,15 @@ export function Header({ me }: HeaderProps): JSX.Element { >
+ + + Peristerónas + + {!me.loggedIn && ( From c0cd627d50175d8ec16e583eb1c135c38faad794 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sat, 4 May 2024 10:40:08 +0200 Subject: [PATCH 308/377] Fix status on ProjectCards (#307) * status fix * rm space --- frontend/public/locales/en/translation.json | 2 ++ frontend/public/locales/nl/translation.json | 2 ++ .../src/pages/project/projectDeadline/ProjectDeadlineCard.tsx | 4 +++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index af275341..f59e9825 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -133,6 +133,8 @@ "course": "Course", "SUCCESS": "Success", "FAIL": "Fail", + "RUNNING": "Is running", + "LATE": "Late", "deadlinesOnDay": "Deadlines on: ", "noDeadline": "No deadlines", "no_submission_yet" : "No submission yet", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 21d33f59..4e1bda1f 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -66,6 +66,8 @@ "last_submission": "Laatste indiening", "SUCCESS": "Geslaagd", "FAIL": "Gefaald", + "RUNNING": "Aan het lopen", + "LATE": "Te laat", "deadlinesOnDay": "Deadlines op: ", "noDeadline": "Geen deadlines", "no_submission_yet" : "Nog geen indiening", diff --git a/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx index b3ed7413..129e0ee4 100644 --- a/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx +++ b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx @@ -31,7 +31,9 @@ export const ProjectDeadlineCard: React.FC = ({ deadlines, sh + (project.short_submission.submission_status === 'SUCCESS' ? 'green' : + (project.short_submission.submission_status === 'RUNNING' ? '#686868' : 'red') + ) : '#686868'}}> {project.title} {showCourse && ( From 1e039ab09e769ce957dcfb167e73b5ab08d067a5 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 11:20:38 +0200 Subject: [PATCH 309/377] Fix #253 (#254) --- .../projects/project_assignment_file.py | 18 ++++++++++++++++-- .../pages/project/projectView/ProjectView.tsx | 11 ++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 01bf59ee..4cbd5e43 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -5,7 +5,7 @@ import os from urllib.parse import urljoin -from flask import send_from_directory +from flask import send_from_directory, request from flask_restful import Resource @@ -28,8 +28,22 @@ def get(self, project_id): Get the assignment files of a project """ + language = request.args.get('lang') directory_path = os.path.abspath(os.path.join(UPLOAD_FOLDER, str(project_id))) - assignment_file = os.path.join(directory_path, ASSIGNMENT_FILE_NAME) + file_name = ASSIGNMENT_FILE_NAME + if language: + potential_file = f"assignment_{language}.md" + if os.path.isfile(os.path.join(directory_path, potential_file)): + file_name = potential_file + else: + # Find any .md file that starts with "assignment" + for filename in os.listdir(directory_path): + if filename.startswith("assignment") and filename.endswith(".md"): + file_name = filename + break + + + assignment_file = os.path.join(directory_path, file_name) if not os.path.isfile(assignment_file): # no file is found so return 404 diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index d3d9a491..5d93c41a 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -15,6 +15,7 @@ import SubmissionCard from "./SubmissionCard"; import { Course } from "../../../types/course"; import { Title } from "../../../components/Header/Title"; import { authenticatedFetch } from "../../../utils/authenticated-fetch"; +import i18next from "i18next"; const API_URL = import.meta.env.VITE_API_HOST; @@ -41,7 +42,9 @@ export default function ProjectView() { response.json().then((data) => { const projectData = data["data"]; setProjectData(projectData); - authenticatedFetch(`${API_URL}/courses/${projectData.course_id}`).then((response) => { + authenticatedFetch( + `${API_URL}/courses/${projectData.course_id}` + ).then((response) => { if (response.ok) { response.json().then((data) => { setCourseData(data["data"]); @@ -52,7 +55,9 @@ export default function ProjectView() { } }); - authenticatedFetch(`${API_URL}/projects/${projectId}/assignment`).then((response) => { + authenticatedFetch( + `${API_URL}/projects/${projectId}/assignment?lang=${i18next.language}` + ).then((response) => { if (response.ok) { response.text().then((data) => setAssignmentRawText(data)); } @@ -73,7 +78,7 @@ export default function ProjectView() { {projectData && ( - + <Title title={projectData.title} /> <CardHeader color="secondary" title={projectData.title} From ff90ff4ac0effd257570c5f5ffb10f77d5157faf Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sat, 4 May 2024 11:20:59 +0200 Subject: [PATCH 310/377] Seeder for easier demo and frontend testing (#291) * basic seeder provider, course titles, usernames and random uid * seeder creates and populates a few courses with random students where given uid is teacher * ffix * relocate, weird project deadline bug tho * seeder go bvv * fun with linter * lint * removed leading spaces * batch operations and documentation cleanup * bad env name * more batch and better try except finally * specific error * close * projects have 0-2 deadlines now, also randomized numprojects from fixed 2 to random 1-3 * 1-3 * import not good * toml test * toml * faker is dev * linter mad * titles now in txt file * completed toml info * fixed parsing error --------- Co-authored-by: Aron Buzogany <aronsaps@gmail.com> --- backend/db_construct.sql | 4 +- backend/dev-requirements.txt | 1 + backend/pyproject.toml | 13 ++ backend/requirements.txt | 2 +- backend/seeder/__init__.py | 0 backend/seeder/seeder.py | 261 +++++++++++++++++++++++++++++++++++ backend/seeder/titles.txt | 208 ++++++++++++++++++++++++++++ 7 files changed, 486 insertions(+), 3 deletions(-) create mode 100644 backend/pyproject.toml create mode 100644 backend/seeder/__init__.py create mode 100644 backend/seeder/seeder.py create mode 100644 backend/seeder/titles.txt diff --git a/backend/db_construct.sql b/backend/db_construct.sql index f9a31e60..b4614151 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -47,7 +47,7 @@ CREATE TYPE deadline AS( CREATE TABLE projects ( project_id INT GENERATED ALWAYS AS IDENTITY, - title VARCHAR(50) NOT NULL, + title VARCHAR(100) NOT NULL, description TEXT NOT NULL, deadlines deadline[], course_id INT NOT NULL, @@ -65,7 +65,7 @@ CREATE TABLE submissions ( project_id INT NOT NULL, grading FLOAT CHECK (grading >= 0 AND grading <= 20), submission_time TIMESTAMP WITH TIME ZONE NOT NULL, - submission_path VARCHAR(50) NOT NULL, + submission_path VARCHAR(255) NOT NULL, submission_status submission_status NOT NULL, PRIMARY KEY(submission_id), CONSTRAINT fk_project FOREIGN KEY(project_id) REFERENCES projects(project_id) ON DELETE CASCADE, diff --git a/backend/dev-requirements.txt b/backend/dev-requirements.txt index fa950d3d..0263c7e7 100644 --- a/backend/dev-requirements.txt +++ b/backend/dev-requirements.txt @@ -2,3 +2,4 @@ pytest pylint pylint-flask pyyaml +faker \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 00000000..c1c45b42 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,13 @@ +[tool.poetry] +name = "Peristeronas" +version = "1.0" +description = "Project submission platform" +authors = ["Aron","Gerwoud","Siebe","Matisse","Warre","Cedric"] +packages = [ + { include = "project/models" }, + { include = "seeder" }, +] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 12a02f7e..529099ca 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -12,4 +12,4 @@ SQLAlchemy~=2.0.27 requests>=2.31.0 waitress flask_swagger_ui -flask_executor +flask_executor \ No newline at end of file diff --git a/backend/seeder/__init__.py b/backend/seeder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/seeder/seeder.py b/backend/seeder/seeder.py new file mode 100644 index 00000000..20b320c0 --- /dev/null +++ b/backend/seeder/seeder.py @@ -0,0 +1,261 @@ +"""Seeder file does the actual seeding of the db""" +import argparse +import os +import random +import string +from datetime import datetime, timedelta + +from dotenv import load_dotenv +from faker import Faker +from faker.providers import DynamicProvider +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy_utils import register_composites + +from project.models.course import Course +from project.models.course_relation import CourseAdmin, CourseStudent +from project.models.project import Project +from project.models.submission import Submission, SubmissionStatus +from project.models.user import User +from project.sessionmaker import Session as session_maker + +load_dotenv() + +UPLOAD_URL = os.getenv("UPLOAD_FOLDER") + +fake = Faker() + +# Get the directory of the current script +script_dir = os.path.dirname(os.path.realpath(__file__)) + +# Construct the path to titles.txt relative to the script directory +titles_path = os.path.join(script_dir, 'titles.txt') + +with open(titles_path, 'r', encoding='utf-8') as file: + # Read the lines of the file and strip newline characters + titles = [line.strip() for line in file] + +course_title_provider = DynamicProvider( # Custom course titles. + provider_name="course_titles", + elements=titles, +) +fake.add_provider(course_title_provider) + + +def generate_course_name(): + """Generates a course name chosen from the predefined provider""" + return fake.course_titles() + + +def generate_random_uid(length=8): + """Generates a random uid of given length""" + characters = string.ascii_letters + string.digits + return ''.join(random.choice(characters) for _ in range(length)) + + +def teacher_generator(): + """Generates a teacher user object""" + return user_generator('TEACHER') + + +def student_generator(): + """Generates a student user object""" + return user_generator('STUDENT') + + +def admin_generator(): + """Generates an admin user object""" + return user_generator('ADMIN') + + +def user_generator(role): + """Generates a user object with the given role""" + user = User(uid=generate_random_uid(), + role=role, + display_name=fake.name()) + return user + + +def course_student_generator(course_id, uid): + """Generates a course student relation object""" + return CourseStudent(course_id=course_id, uid=uid) + + +def course_admin_generator(course_id, uid): + """Generates a course admin relation object""" + return CourseAdmin(course_id=course_id, uid=uid) + + +def generate_course(teacher_uid): + """Generates a course object with a random name and the given teacher uid""" + course = Course(name=generate_course_name(), + teacher=teacher_uid) + return course + + +def generate_projects(course_id, num_projects): + """Generates a list of project objects with random future deadlines""" + projects = [] + for _ in range(num_projects): + deadlines = [] + # Generate a random number of deadlines (0-2) + num_deadlines = random.randint(0, 2) + + for _ in range(num_deadlines): + if random.random() < 1/3: + past_datetime = datetime.now() - timedelta(days=random.randint(1, 30)) + deadline = (fake.catch_phrase(), past_datetime) + else: + future_datetime = datetime.now() + timedelta(days=random.randint(1, 30)) + deadline = (fake.catch_phrase(), future_datetime) + deadlines.append(deadline) + project = Project( + title=fake.catch_phrase(), + description=fake.catch_phrase(), + deadlines=deadlines, + course_id=course_id, + visible_for_students=random.choice([True, False]), + archived=random.choice([True, False]), + regex_expressions=[] + ) + projects.append(project) + return projects + + +def generate_submissions(project_id, student_uid): + """Generates a list of submissions with random status""" + submissions = [] + statusses = [SubmissionStatus.SUCCESS, SubmissionStatus.FAIL, + SubmissionStatus.LATE, SubmissionStatus.RUNNING] + num_submissions = random.randint(0, 2) + for _ in range(num_submissions): + submission = Submission(project_id=project_id, + uid=student_uid, + submission_time=datetime.now(), + submission_path="", + submission_status=random.choice(statusses)) + graded = random.choice([True, False]) + if graded and submission.submission_status == "SUCCESS": + submission.grading = random.randint(0, 20) + submissions.append(submission) + return submissions + + +def into_the_db(my_uid): + """Populates the db with 5 courses where my_uid is teacher and 5 where he is student""" + try: + session = session_maker() # setup the db session + connection = session.connection() + register_composites(connection) + + students = [] + # make a random amount of 100-200 students which we can use later to populate courses + num_students = random.randint(100, 200) + students = [student_generator() for _ in range(num_students)] + session.add_all(students) + session.commit() + + num_teachers = random.randint(5, 10) + teachers = [teacher_generator() for _ in range(num_teachers)] + session.add_all(teachers) + session.commit() # only after commit uid becomes available + + for _ in range(5): # 5 courses where my_uid is teacher + course_id = insert_course_into_db_get_id(session, my_uid) + # Add students to the course + subscribed_students = populate_course_students( + session, course_id, students) + populate_course_projects( + session, course_id, subscribed_students, my_uid) + + for _ in range(5): # 5 courses where my_uid is a student + teacher_uid = teachers[random.randint(0, len(teachers)-1)].uid + course_id = insert_course_into_db_get_id(session, teacher_uid) + subscribed_students = populate_course_students( + session, course_id, students) + subscribed_students.append(my_uid) # my_uid is also a student + populate_course_projects( + session, course_id, subscribed_students, teacher_uid) + except SQLAlchemyError as e: + if session: # possibly error resulted in session being null + session.rollback() + raise e + finally: + session.close() + + +def insert_course_into_db_get_id(session, teacher_uid): + """Inserts a course with teacher_uid as teacher into the db and returns the course_id""" + course = generate_course(teacher_uid) + session.add(course) + session.commit() + return course.course_id + + +def populate_course_students(session, course_id, students): + """Populates the course with students and returns their uids as a list""" + num_students_in_course = random.randint(5, 30) + subscribed_students = random.sample(students, num_students_in_course) + student_relations = [course_student_generator(course_id, student.uid) + for student in subscribed_students] + + session.add_all(student_relations) + session.commit() + + return [student.uid for student in subscribed_students] + + +def populate_course_projects(session, course_id, students, teacher_uid): + """Populates the course with projects and submissions, also creates the files""" + teacher_relation = course_admin_generator(course_id, teacher_uid) + session.add(teacher_relation) + session.commit() + + num_projects = random.randint(1, 3) + projects = generate_projects(course_id, num_projects) + session.add_all(projects) + session.commit() + for project in projects: + project_id = project.project_id + # Write assignment.md file + assignment_content = fake.text() + assignment_file_path = os.path.join( + UPLOAD_URL, "projects", str(project_id), "assignment.md") + os.makedirs(os.path.dirname(assignment_file_path), exist_ok=True) + with open(assignment_file_path, "w", encoding="utf-8") as assignment_file: + assignment_file.write(assignment_content) + populate_project_submissions(session, students, project_id) + + +def populate_project_submissions(session, students, project_id): + """Make submissions, 0 1 or 2 for each project per student""" + for student in students: + submissions = generate_submissions(project_id, student) + session.add_all(submissions) + session.commit() + for submission in submissions: + submission_directory = os.path.join(UPLOAD_URL, "projects", str( + project_id), "submissions", str(submission.submission_id), "submission") + os.makedirs(submission_directory, exist_ok=True) + submission_file_path = os.path.join( + submission_directory, "submission.md") + with open(submission_file_path, "w", encoding="utf-8") as submission_file: + submission_file.write(fake.text()) + + submission.submission_path = submission_directory + session.commit() # update submission path + +# Create a function to parse command line arguments +def parse_args(): + """Parse the given uid from the command line""" + parser = argparse.ArgumentParser(description='Populate the database') + parser.add_argument('my_uid', type=str, help='Your UID') + return parser.parse_args() + +# Main function to run when script is executed +def main(): + """Parse arguments, pass them to into_the_db function""" + args = parse_args() + into_the_db(args.my_uid) + +if __name__ == '__main__': + main() diff --git a/backend/seeder/titles.txt b/backend/seeder/titles.txt new file mode 100644 index 00000000..cd5a42ca --- /dev/null +++ b/backend/seeder/titles.txt @@ -0,0 +1,208 @@ +Computer Science +Principles of Economics +Modern Literature +Organic Chemistry +World History +Calculus and Analytic Geometry +Psychology +Microbiology Fundamentals +Principles of Marketing +Environmental Science +Sociology +Financial Accounting +Political Science and Government +Human Anatomy +Business Ethics +Philosophy +Statistics for Social Sciences +Cell Biology +Anthropology +Principles of Management +Macroeconomics +General Physics +English Composition +Human Physiology +Developmental Psychology +Linguistics +Genetics and Genomics +Principles of Finance +Art History +Microeconomics +Anatomy and Physiology +Marketing +Astronomy +Political Science +Microeconomics +Business +Cultural Anthropology +American History +World Religions +Chemistry +Environmental Science +Communication +General Chemistry +Cultural Anthropology +Human Biology +Theatre +Public Speaking +International Relations +Sociology +Criminal Justice +Statistics +Human Anatomy +Western Civilization +Literature +Biochemistry +Physical Anthropology +Human Physiology +Creative Writing +Film Studies +Music +Ethics +Philosophy of Science +Philosophy of Mind +Philosophy of Language +Political Philosophy +Philosophy of Religion +Epistemology +Metaphysics +Logic +Symbolic Logic +Modal Logic +Mathematical Logic +Computer Science +Programming +Algorithms +Data Structures +Software Engineering +Computer Networks +Operating Systems +Database Systems +Artificial Intelligence +Machine Learning +Computer Vision +Natural Language Processing +Robotics +Human-Computer Interaction +Virtual Reality +Augmented Reality +Web Development +Mobile Development +Development +Cybersecurity +Cryptography +Digital Forensics +Cloud Computing +Big Data +Data Science +Data Analytics +Data Visualization +Business Intelligence +Information Systems +E-commerce +Web Design +User Experience Design +User Interface Design +Graphic Design +Multimedia Design +Animation +Digital Art +Photography +Videography +Audio Production +Sound Design +Music Production +Film Production +Screenwriting +Directing +Cinematography +Film Editing +Visual Effects +Motion Graphics +3D Modeling +3D Animation +Design +Programming +Art +Sound +Narrative +Testing +Marketing +Publishing +Monetization +Analytics +Localization +Development Tools +Engines +AI +Networking +Servers +Security +Design Patterns +Theory +History +Culture +Studies +Journalism +Criticism +Law +Ethics +Philosophy +Psychology +Sociology +Anthropology +Archaeology +Economics +Marketing +Management +Development Methodologies +Production +Design Documents +Prototyping +Testing +Quality Assurance +Localization +Voice Acting +Music Composition +Sound Design +Art Direction +Animation +Character Design +Environment Design +Level Design +Storytelling +Writing +Dialogue +Cutscenes +Worldbuilding +Concept Art +Production Design +User Interface Design +User Experience Design +Interaction Design +Control Design +Camera Design +Interface Design +HUD Design +Menu Design +Navigation Design +Inventory Design +Map Design +Mini-map Design +Tutorial Design +Help System Design +Accessibility Design +Reward System Design +Progression System Design +Achievement System Design +Leaderboard Design +Social System Design +Community System Design +Feedback System Design +Chat System Design +Messaging System Design +Notification System Design +Ranking System Design +Matchmaking System Design +Voting System Design +Auction System Design \ No newline at end of file From 9f2540a458c173b48034b0bf065ec90697d96855 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 14:29:21 +0200 Subject: [PATCH 311/377] Redirecting when creation of project is succesful or showing error message on failure (#313) * Fix #275 * removed unused import * enhanced project submission error message in dutch --- frontend/public/locales/en/translation.json | 4 ++- frontend/public/locales/nl/translation.json | 4 ++- .../components/ProjectForm/ProjectForm.tsx | 29 +++++++++++++++++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index f59e9825..67729516 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -123,7 +123,9 @@ "noFilesPlaceholder": "No assignment files given yet", "noRegexPlaceholder": "No regex added yet", "clearSelected": "Clear Selection", - "faultySubmission": "Some fields were left open or there is no valid runner/file combination" + "faultySubmission": "Some fields were left open or there is no valid runner/file combination", + "unauthorized": "You are unauthorized to upload a project for this course", + "submissionError": "Submission failed, please try again" }, "student" : { "myProjects": "My Projects", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 4e1bda1f..723d7443 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -91,7 +91,9 @@ "uploadError": "Project is niet goed geformatteerd", "noDeadlinesPlaceholder": "Nog geen opgegeven deadlines", "noFilesPlaceholder": "Nog geen opgave bestanden geupload", - "noRegexPlaceholder": "Nog geen regex toegevoegd" + "noRegexPlaceholder": "Nog geen regex toegevoegd", + "unauthorized": "U heeft niet de juiste rechten om een project aan te maken voor dit vak", + "submissionError": "Er is een fout opgetreden bij het indienen van uw project, probeer het later opnieuw." }, "projectView": { "submitNetworkError": "Er is iets mislopen bij het opslaan van uw indiening. Probeer het later opnieuw.", diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index f1813232..f2273a87 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -22,7 +22,7 @@ import DeleteIcon from "@mui/icons-material/Delete"; import DeadlineCalender from "../Calender/DeadlineCalender.tsx"; import { Deadline } from "../../types/deadline"; import {InfoOutlined} from "@mui/icons-material"; -import {Link, useLoaderData, useLocation} from "react-router-dom"; +import {Link, useLoaderData, useLocation, useNavigate} from "react-router-dom"; import FolderDragDrop from "../FolderUpload/FolderUpload.tsx"; import TabPanel from "@mui/lab/TabPanel"; import {TabContext} from "@mui/lab"; @@ -30,6 +30,7 @@ import FileStuctureForm from "./FileStructureForm.tsx"; import AdvancedRegex from "./AdvancedRegex.tsx"; import RunnerSelecter from "./RunnerSelecter.tsx"; import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; +import i18next from "i18next"; interface Course { course_id: string; @@ -77,6 +78,7 @@ export default function ProjectForm() { const [runner, setRunner] = useState<string>(''); const [validRunner, setValidRunner] = useState(true); const [validSubmission, setValidSubmission] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); const courses = useLoaderData() as Course[] @@ -84,6 +86,8 @@ export default function ProjectForm() { const [courseName, setCourseName] = useState<string>(''); const location = useLocation(); + const navigate = useNavigate(); + useEffect(() =>{ const urlParams = new URLSearchParams(location.search); const initialCourseId = urlParams.get('course_id') || ''; @@ -142,9 +146,15 @@ export default function ProjectForm() { if (runner === "CUSTOM") { setValidRunner(constainsDocker); + if (!constainsDocker) { + setErrorMessage(t("faultySubmission")); + } setValidSubmission(constainsDocker); } else { setValidRunner(containsRuntest); + if(!containsRuntest) { + setErrorMessage(t("faultySubmission")); + } setValidSubmission(containsRuntest); } } @@ -177,6 +187,7 @@ export default function ProjectForm() { if (!assignmentFile || !validRunner) { setValidSubmission(false); + setErrorMessage(t("faultySubmission")); return; } @@ -212,8 +223,20 @@ export default function ProjectForm() { }) if (!response.ok) { - throw new Error(t("uploadError")); + setValidSubmission(false); + if (response.status === 403) { + setErrorMessage(t("unauthorized")); + } + else { + setErrorMessage(t("submissionError")); + } + return; } + + response.json().then((data) => { + const projectData = data.data; + navigate(`/${i18next.language}/projects/${projectData.project_id}`); + }) } const handleCourseChange = (e: SelectChangeEvent<string>) => { @@ -422,7 +445,7 @@ export default function ProjectForm() { { !validSubmission && ( <Typography style={{color: 'red', paddingTop: "20px" }}> - {t("faultySubmission")} ⚠️ + {errorMessage} ⚠️ </Typography> ) } From c66561171b0b319d4b2941a63b3243fb0d0efb25 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 15:23:42 +0200 Subject: [PATCH 312/377] When doing GET on courses, only returning courses you are authorized to see (#282) * Fix #242 * fixed linting * Added functionality for url queries * fixed tests maybe * changed error code on wrong parameter in tests to 500 * actually changed the assert this time * courses currently ignores wrong filters * ignore filter tests on courses for now --------- Co-authored-by: Siebe Vlietinck <siebe.vlietinck@ugent.be> --- backend/project/endpoints/courses/courses.py | 66 ++++++++++++++++--- .../tests/endpoints/course/courses_test.py | 5 +- backend/tests/endpoints/endpoint.py | 5 +- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index b550b60f..04b645ed 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -12,10 +12,15 @@ from flask import request from flask_restful import Resource +from sqlalchemy import union, select +from sqlalchemy.exc import SQLAlchemyError + from project.models.course import Course -from project.utils.query_agent import query_selected_from_model, insert_into_model -from project.utils.authentication import login_required, authorize_teacher +from project.models.course_relation import CourseAdmin, CourseStudent +from project.utils.query_agent import insert_into_model +from project.utils.authentication import login_required_return_uid, authorize_teacher from project.endpoints.courses.courses_utils import check_data +from project.db_in import db load_dotenv() API_URL = getenv("API_HOST") @@ -24,20 +29,61 @@ class CourseForUser(Resource): """Api endpoint for the /courses link""" - @login_required - def get(self): + @login_required_return_uid + def get(self, uid=None): """ " Get function for /courses this will be the main endpoint to get all courses and filter by given query parameter like /courses?parameter=... parameters can be either one of the following: teacher,ufora_id,name. """ - return query_selected_from_model( - Course, - RESPONSE_URL, - url_mapper={"course_id": RESPONSE_URL}, - filters=request.args - ) + try: + + filter_params = request.args.to_dict() + + # Start with a base query + base_query = select(Course) + + # Apply filters dynamically if they are provided + for param, value in filter_params.items(): + if value: + attribute = getattr(Course, param, None) + if attribute: + base_query = base_query.filter(attribute == value) + + # Define the role-specific queries + student_courses = base_query.join( + CourseStudent, + Course.course_id == CourseStudent.course_id).filter( + CourseStudent.uid == uid) + admin_courses = base_query.join( + CourseAdmin, + Course.course_id == CourseAdmin.course_id).filter( + CourseAdmin.uid == uid) + teacher_courses = base_query.filter(Course.teacher == uid) + + # Combine the select statements using union to remove duplicates + all_courses_query = union(student_courses, admin_courses, teacher_courses) + + # Execute the union query and fetch all results as Course instances + courses = db.session.execute(all_courses_query).mappings().all() + courses_data = [dict(course) for course in courses] + + for course in courses_data: + course["course_id"] = urljoin(f"{RESPONSE_URL}/", str(course['course_id'])) + + return { + "data": courses_data, + "url": RESPONSE_URL, + "message": "Courses fetched successfully" + } + + except SQLAlchemyError: + db.session.rollback() + return { + "message": "An error occurred while fetching the courses", + "url": RESPONSE_URL + }, 500 @authorize_teacher def post(self, teacher_id=None): diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 123e89a9..99ee348e 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -112,7 +112,7 @@ def test_data_fields(self, data_field_type_test: tuple[str, Any, str, dict[str, ### QUERY PARAMETER ### # Test a query parameter, should return [] for wrong values query_parameter_tests = \ - query_parameter_tests("/courses", "get", "student", [f.name for f in fields(Course)]) + query_parameter_tests("/courses", "get", "teacher", [f.name for f in fields(Course)]) @mark.parametrize("query_parameter_test", query_parameter_tests, indirect=True) def test_query_parameters(self, query_parameter_test: tuple[str, Any, str, bool]): @@ -124,7 +124,7 @@ def test_query_parameters(self, query_parameter_test: tuple[str, Any, str, bool] ### COURSES ### def test_get_courses(self, client: FlaskClient, courses: list[Course]): """Test getting all courses""" - csrf = get_csrf_from_login(client, "student") + csrf = get_csrf_from_login(client, "teacher") response = client.get("/courses", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 data = [course["name"] for course in response.json["data"]] @@ -219,7 +219,6 @@ def test_post_courses(self, client: FlaskClient, teacher: User): } ) assert response.status_code == 201 - csrf = get_csrf_from_login(client, "student") response = client.get("/courses?name=test", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 data = response.json["data"][0] diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index 1be6b3be..c3fb5928 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -69,7 +69,7 @@ def query_parameter_tests( new_endpoint = endpoint + "?parameter=0" tests.append(param( (new_endpoint, method, token, True), - id = f"{new_endpoint} {method.upper()} {token} (parameter 0 400)" + id = f"{new_endpoint} {method.upper()} {token} (parameter 0 500)" )) for parameter in parameters: @@ -117,7 +117,8 @@ def query_parameter(self, test: tuple[str, Any, str, bool]): endpoint, method, csrf, wrong_parameter = test response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) - assert wrong_parameter == (response.status_code == 400) + if wrong_parameter: + assert wrong_parameter == (response.status_code == 200) if not wrong_parameter: assert response.json["data"] == [] From f9b6324b68b7082354a53554ea43c295e3e590b4 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 15:41:14 +0200 Subject: [PATCH 313/377] Fix #239 (#316) --- frontend/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/index.html b/frontend/index.html index 43fdb5e7..18eb2100 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ <html> <head> <meta charset="UTF-8" /> - <link rel="icon" type="image/png" href="/logo_ugent.png" /> + <link rel="icon" type="image/png" href="/img/logo_ugent.png" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Ugent-3 From ef6afb5625901da3db1f30dc5e0fe4bc279d1a7d Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 15:55:21 +0200 Subject: [PATCH 314/377] Fix #238 (#317) --- frontend/public/locales/en/translation.json | 4 ++-- frontend/public/locales/nl/translation.json | 4 ++-- frontend/src/pages/home/HomePage.tsx | 5 +++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 67729516..86ed53b3 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -138,10 +138,10 @@ "RUNNING": "Is running", "LATE": "Late", "deadlinesOnDay": "Deadlines on: ", - "noDeadline": "No deadlines", + "noDeadline": "No deadlines on this day", "no_submission_yet" : "No submission yet", "loading": "Loading...", - "no_projects": "There are no projects here." + "no_projects": "There are no projects here, sign up for a course to see projects" }, "projectsOverview": { "past_deadline": "Past Projects", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 723d7443..94a5cbcf 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -69,10 +69,10 @@ "RUNNING": "Aan het lopen", "LATE": "Te laat", "deadlinesOnDay": "Deadlines op: ", - "noDeadline": "Geen deadlines", + "noDeadline": "Geen deadlines op deze dag", "no_submission_yet" : "Nog geen indiening", "loading": "Laden...", - "no_projects": "Er zijn hier geen projecten." + "no_projects": "Er zijn hier geen projecten, meld je aan voor een vak om projecten te zien" }, "projectForm": { diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index e8df53ed..35775325 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -6,6 +6,7 @@ import { Grid, Container, Badge, + CardHeader, } from "@mui/material"; import { DateCalendar } from "@mui/x-date-pickers/DateCalendar"; import { DayCalendarSkeleton, LocalizationProvider } from "@mui/x-date-pickers"; @@ -148,8 +149,8 @@ export default function HomePage() { + - {t("myProjects")} {futureProjects.length + noDeadlineProject.length > 0 ? ( <> @@ -164,8 +165,8 @@ export default function HomePage() { + - {t("deadlines")} {pastDeadlines.length > 0 ? ( ) : ( From 27b09ed382fe52fb696e9f2fec81f50f0dd8e5c8 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 15:56:48 +0200 Subject: [PATCH 315/377] Fix #323 (#324) --- backend/project/endpoints/courses/join.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/courses/join.py b/backend/project/endpoints/courses/join.py index 6a3c70ec..31f57608 100644 --- a/backend/project/endpoints/courses/join.py +++ b/backend/project/endpoints/courses/join.py @@ -68,7 +68,10 @@ def post(self, uid=None): # pylint: disable=too-many-return-statements relation = course_relation.query.filter_by(course_id=course_id, uid=uid).first() if relation: response["message"] = "User already in course" - return response, 400 + response["data"] = { + "course_id": course_id + } + return response, 409 except SQLAlchemyError: response["message"] = "Internal server error" return response, 500 From e9d0b9acacc170f8303d297f35fe636da64ae608 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 15:57:14 +0200 Subject: [PATCH 316/377] added paper islands, made project cards of equal size, displaying username instead of uid (#311) --- .../Courses/CourseDetailTeacher.tsx | 491 +++++++++++++----- .../src/components/Courses/CourseUtils.tsx | 11 +- 2 files changed, 358 insertions(+), 144 deletions(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index 60564260..df712e43 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -14,19 +14,34 @@ import { Menu, MenuItem, Paper, - Typography + Typography, } from "@mui/material"; import { ChangeEvent, useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Course, apiHost, getIdFromLink, getNearestFutureDate, getUserName, appHost, ProjectDetail } from "./CourseUtils"; -import { Link, useNavigate, NavigateFunction, useLoaderData } from "react-router-dom"; +import { + Course, + apiHost, + getIdFromLink, + getNearestFutureDate, + getUser, + appHost, + ProjectDetail, +} from "./CourseUtils"; +import { + Link, + useNavigate, + NavigateFunction, + useLoaderData, +} from "react-router-dom"; import { Title } from "../Header/Title"; -import ClearIcon from '@mui/icons-material/Clear'; +import ClearIcon from "@mui/icons-material/Clear"; import { timeDifference } from "../../utils/date-utils"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; +import i18next from "i18next"; +import { Me } from "../../types/me"; -interface UserUid{ - uid: string +interface UserUid { + uid: string; } /** @@ -35,16 +50,19 @@ interface UserUid{ * @param courseId - The ID of the course. * @param uid - The UID of the admin. */ -function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: string): void { +function handleDeleteAdmin( + navigate: NavigateFunction, + courseId: string, + uid: string +): void { authenticatedFetch(`${apiHost}/courses/${courseId}/admins`, { - method: 'DELETE', + method: "DELETE", body: JSON.stringify({ - "admin_uid": uid - }) - }) - .then(() => { - navigate(0); - }); + admin_uid: uid, + }), + }).then(() => { + navigate(0); + }); } /** @@ -53,19 +71,22 @@ function handleDeleteAdmin(navigate: NavigateFunction, courseId: string, uid: st * @param courseId - The ID of the course. * @param uid - The UID of the admin. */ -function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: string[]): void { +function handleDeleteStudent( + navigate: NavigateFunction, + courseId: string, + uids: string[] +): void { authenticatedFetch(`${apiHost}/courses/${courseId}/students`, { - method: 'DELETE', + method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - "students": uids - }) - }) - .then(() => { - navigate(0); - }); + students: uids, + }), + }).then(() => { + navigate(0); + }); } /** @@ -73,21 +94,23 @@ function handleDeleteStudent(navigate: NavigateFunction, courseId: string, uids: * @param navigate - The navigate function from react-router-dom. * @param courseId - The ID of the course. */ -function handleDeleteCourse(navigate: NavigateFunction, courseId: string): void { +function handleDeleteCourse( + navigate: NavigateFunction, + courseId: string +): void { authenticatedFetch(`${apiHost}/courses/${courseId}`, { - method: 'DELETE', + method: "DELETE", }).then((response) => { - if(response.ok){ + if (response.ok) { navigate(-1); - } - else if(response.status === 404){ + } else if (response.status === 404) { navigate(-1); } }); } /** - * + * * @returns A jsx component representing the course detail page for a teacher */ export function CourseDetailTeacher(): JSX.Element { @@ -102,19 +125,48 @@ export function CourseDetailTeacher(): JSX.Element { }; const courseDetail = useLoaderData() as { - course: Course , - projects:ProjectDetail[] , - admins: UserUid[], - students: UserUid[] + course: Course; + projects: ProjectDetail[]; + admins: UserUid[]; + students: UserUid[]; }; const { course, projects, admins, students } = courseDetail; - const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); + const [adminObjects, setAdminObjects] = useState([]); + const [studentObjects, setStudentObjects] = useState([]); + const { t } = useTranslation("translation", { + keyPrefix: "courseDetailTeacher", + }); const { i18n } = useTranslation(); const lang = i18n.language; const navigate = useNavigate(); - const handleCheckboxChange = (event: ChangeEvent, uid: string) => { + useEffect(() => { + setAdminObjects([]); + admins.forEach((admin) => { + getUser(admin.uid).then((user: Me) => { + setAdminObjects((prev) => { + return [...prev, user]; + }); + }); + }); + }, [admins]); + + useEffect(() => { + setStudentObjects([]); + students.forEach((student) => { + getUser(student.uid).then((user: Me) => { + setStudentObjects((prev) => { + return [...prev, user]; + }); + }); + }); + }, [students]); + + const handleCheckboxChange = ( + event: ChangeEvent, + uid: string + ) => { if (event.target.checked) { setSelectedStudents((prevSelected) => [...prevSelected, uid]); } else { @@ -123,51 +175,122 @@ export function CourseDetailTeacher(): JSX.Element { ); } }; - + return ( <> - - - - {t('projects')}: - + + + +
+ {t("projects")}: + +
- + + +
- - - - - {t('admins')}: + + + + + {t("admins")}: - {admins.map((admin) => ( - + {adminObjects.map((admin) => ( + - {getUserName(admin.uid)} + + {admin.display_name} + - + ))} - - {t('students')}: - - + + + {t("students")}: + - handleDeleteStudent(navigate, course.course_id, selectedStudents)}> + + + handleDeleteStudent( + navigate, + course.course_id, + selectedStudents + ) + } + > - {t('deleteSelected')} + {t("deleteSelected")} - - - - - + + + + + + + + + @@ -180,33 +303,48 @@ export function CourseDetailTeacher(): JSX.Element { * @param projects - The array of projects. * @returns Either a place holder for no projects or a grid of cards describing the projects. */ -function EmptyOrNotProjects({projects}: {projects: ProjectDetail[]}): JSX.Element { - const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); - if(projects === undefined || projects.length === 0){ - return ( - {t('noProjects')} - ); - } - else{ +function EmptyOrNotProjects({ + projects, +}: { + projects: ProjectDetail[]; +}): JSX.Element { + const { t } = useTranslation("translation", { + keyPrefix: "courseDetailTeacher", + }); + if (projects === undefined || projects.length === 0) { + return {t("noProjects")}; + } else { return ( - + {projects?.map((project) => ( - + - + - {getNearestFutureDate(project.deadlines) && - ( + {getNearestFutureDate(project.deadlines) && ( - {`${t('deadline')}: ${getNearestFutureDate(project.deadlines)?.date.toLocaleDateString()}`} + {`${t("deadline")}: ${getNearestFutureDate(project.deadlines)?.date.toLocaleDateString()}`} )} - - + + @@ -223,14 +361,29 @@ function EmptyOrNotProjects({projects}: {projects: ProjectDetail[]}): JSX.Elemen * @param admin - The admin in question. * @returns Either nothing, if the admin uid is of teacher or a delete button. */ -function EitherDeleteIconOrNothing({admin, course, navigate} : {admin:UserUid, course:Course, navigate: NavigateFunction}) : JSX.Element{ - if(course.teacher === getIdFromLink(admin.uid)){ +function EitherDeleteIconOrNothing({ + admin, + course, + navigate, +}: { + admin: UserUid; + course: Course; + navigate: NavigateFunction; +}): JSX.Element { + if (course.teacher === getIdFromLink(admin.uid)) { return <>; - } - else{ + } else { return ( - handleDeleteAdmin(navigate,course.course_id,getIdFromLink(admin.uid))}> + + handleDeleteAdmin( + navigate, + course.course_id, + getIdFromLink(admin.uid) + ) + } + > @@ -244,25 +397,48 @@ function EitherDeleteIconOrNothing({admin, course, navigate} : {admin:UserUid, c * @param handleCheckboxChange - The function to handle the checkbox change. * @returns Either a place holder for no students or a grid of checkboxes for the students. */ -function EmptyOrNotStudents({students, selectedStudents, handleCheckboxChange}: {students: UserUid[], selectedStudents: string[], handleCheckboxChange: (event: React.ChangeEvent, studentId: string) => void}): JSX.Element { - if(students.length === 0){ +function EmptyOrNotStudents({ + students, + selectedStudents, + handleCheckboxChange, +}: { + students: Me[]; + selectedStudents: string[]; + handleCheckboxChange: ( + event: React.ChangeEvent, + studentId: string + ) => void; +}): JSX.Element { + if (students.length === 0) { return ( - No students found + + No students found + ); - } - else{ + } else { return ( {students.map((student) => ( - + handleCheckboxChange(event, getIdFromLink(student.uid))} + onChange={(event) => + handleCheckboxChange(event, getIdFromLink(student.uid)) + } /> - {getUserName(student.uid)} + {student.display_name} ))} @@ -271,10 +447,10 @@ function EmptyOrNotStudents({students, selectedStudents, handleCheckboxChange}: } } -interface JoinCode{ - join_code: string, - expiry_time: string, - for_admins: boolean +interface JoinCode { + join_code: string; + expiry_time: string; + for_admins: boolean; } /** @@ -286,104 +462,131 @@ interface JoinCode{ * @param getCodes - Function to get the list of join codes. * @returns The rendered JoinCodeDialog component. */ -function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, open: boolean, handleClose : () => void, anchorEl: HTMLElement | null}) { - const { t } = useTranslation('translation', { keyPrefix: 'courseDetailTeacher' }); +function JoinCodeMenu({ + courseId, + open, + handleClose, + anchorEl, +}: { + courseId: string; + open: boolean; + handleClose: () => void; + anchorEl: HTMLElement | null; +}) { + const { t } = useTranslation("translation", { + keyPrefix: "courseDetailTeacher", + }); const [codes, setCodes] = useState([]); const [expiry_time, setExpiryTime] = useState(null); const [for_admins, setForAdmins] = useState(false); - + const handleInputChange = (event: React.ChangeEvent) => { setExpiryTime(new Date(event.target.value)); }; const handleCopyToClipboard = (join_code: string) => { - navigator.clipboard.writeText(`${appHost}/join-course?code=${join_code}`) + navigator.clipboard.writeText(`${appHost}/join-course?code=${join_code}`); }; const getCodes = useCallback(() => { authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes`, { - method: 'GET', + method: "GET", }) - .then(response => response.json()) - .then(data => { + .then((response) => response.json()) + .then((data) => { const filteredData = data.data.filter((code: JoinCode) => { // Filter out expired codes let expired = false; - if(code.expiry_time !== null){ + if (code.expiry_time !== null) { const expiryTime = new Date(code.expiry_time); const now = new Date(); expired = expiryTime < now; } - + return !expired; }); setCodes(filteredData); - }) + }); }, [courseId]); const handleNewCode = () => { - - const bodyContent: { for_admins: boolean, expiry_time?: string } = { "for_admins": for_admins }; + const bodyContent: { for_admins: boolean; expiry_time?: string } = { + for_admins: for_admins, + }; if (expiry_time !== null) { bodyContent.expiry_time = expiry_time.toISOString(); } authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - body: JSON.stringify(bodyContent) - }) - .then(() => getCodes()) - } + body: JSON.stringify(bodyContent), + }).then(() => getCodes()); + }; const handleDeleteCode = (joinCode: string) => { - authenticatedFetch(`${apiHost}/courses/${courseId}/join_codes/${joinCode}`, + authenticatedFetch( + `${apiHost}/courses/${courseId}/join_codes/${joinCode}`, { - method: 'DELETE', + method: "DELETE", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - "join_code": joinCode - }) - }) - .then(() => getCodes()); - } + join_code: joinCode, + }), + } + ).then(() => getCodes()); + }; useEffect(() => { getCodes(); - }, [t, getCodes ]); + }, [t, getCodes]); return ( - - {t('joinCodes')} + {t("joinCodes")} - - {codes.map((code:JoinCode) => ( - handleCopyToClipboard(code.join_code)} key={code.join_code}> + + {codes.map((code: JoinCode) => ( + handleCopyToClipboard(code.join_code)} + key={code.join_code} + > - - {code.expiry_time ? timeDifference(code.expiry_time) : t('noExpiryDate')} + + + {code.expiry_time + ? timeDifference(code.expiry_time) + : t("noExpiryDate")} + - - {code.for_admins ? t('forAdmins') : t('forStudents')} + + + {code.for_admins ? t("forAdmins") : t("forStudents")} + handleDeleteCode(code.join_code)}> @@ -394,17 +597,21 @@ function JoinCodeMenu({courseId,open,handleClose, anchorEl}: {courseId:string, o ))} - - {t('expiryDate')}: + + + {t("expiryDate")}:{" "} + } /> - + ); -} \ No newline at end of file +} diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 92c55aab..5583db74 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -1,5 +1,6 @@ import { NavigateFunction, Params } from "react-router-dom"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; +import { Me } from "../../types/me"; export interface Course { course_id: string; @@ -39,8 +40,14 @@ export function loggedInToken() { * @param uid - The uid of the user. * @returns The username. */ -export function getUserName(uid: string): string { - return getIdFromLink(uid); +export async function getUser(uid: string): Promise { + return authenticatedFetch(`${apiHost}/users/${getIdFromLink(uid)}`).then((response) => { + if (response.ok) { + return response.json().then((data) => { + return data.data; + }); + } + }) } /** From 8600115ed0ffdf600fd53a403fbf2705b2637875 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 4 May 2024 16:03:21 +0200 Subject: [PATCH 317/377] Added header on error pages and moved env import outside fetchme function (#319) * added header to error pages * moved env import outside function --- frontend/src/pages/error/ErrorBoundary.tsx | 49 +++++++++++++++++++--- frontend/src/utils/fetches/FetchMe.ts | 3 +- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/error/ErrorBoundary.tsx b/frontend/src/pages/error/ErrorBoundary.tsx index d6d48ca9..8a0f7155 100644 --- a/frontend/src/pages/error/ErrorBoundary.tsx +++ b/frontend/src/pages/error/ErrorBoundary.tsx @@ -1,32 +1,69 @@ import { useRouteError, isRouteErrorResponse } from "react-router-dom"; import { ErrorPage } from "./ErrorPage.tsx"; import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { fetchMe } from "../../utils/fetches/FetchMe.ts"; +import { Me } from "../../types/me.ts"; +import { Header } from "../../components/Header/Header.tsx"; /** * This component will render the ErrorPage component with the appropriate data when an error occurs. * @returns The ErrorBoundary component */ export function ErrorBoundary() { + const [me, setMe] = useState(null); + + useEffect(() => { + fetchMe().then((data) => { + setMe(data); + }); + }, []); + + return ( + <> + {me &&
} + + + ); +} + +const ErrorBoundaryPage = () => { const error = useRouteError(); - const { t } = useTranslation('translation', { keyPrefix: 'error' }); + const { t } = useTranslation("translation", { keyPrefix: "error" }); if (isRouteErrorResponse(error)) { if (error.status == 404) { return ( - + ); } else if (error.status == 403) { return ( - + ); } else if (error.status >= 400 && error.status <= 499) { return ( - + ); } else if (error.status >= 500 && error.status <= 599) { return ( - + ); } } -} +}; diff --git a/frontend/src/utils/fetches/FetchMe.ts b/frontend/src/utils/fetches/FetchMe.ts index ff7ab13d..d5e56fe0 100644 --- a/frontend/src/utils/fetches/FetchMe.ts +++ b/frontend/src/utils/fetches/FetchMe.ts @@ -1,7 +1,8 @@ import {authenticatedFetch} from "../authenticated-fetch.ts"; +const API_URL = import.meta.env.VITE_APP_API_HOST; + export const fetchMe = async () => { - const API_URL = import.meta.env.VITE_APP_API_HOST; try { const response = await authenticatedFetch(`${API_URL}/me`, { credentials: "include", From b370591470e2dd5194ceb24eb4c07e659d520520 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Sat, 4 May 2024 16:03:56 +0200 Subject: [PATCH 318/377] added check on posting project (#321) --- backend/project/endpoints/projects/projects.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 5f882c76..e19538ad 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -18,6 +18,7 @@ from project.utils.query_agent import create_model_instance from project.utils.authentication import login_required_return_uid, authorize_teacher from project.endpoints.projects.endpoint_parser import parse_project_params +from project.utils.models.course_utils import is_teacher_of_course from project.utils.models.project_utils import get_course_of_project API_URL = os.getenv('API_HOST') @@ -80,8 +81,11 @@ def post(self, teacher_id=None): Post functionality for project using flask_restfull parse lib """ - project_json = parse_project_params() + + if not is_teacher_of_course(teacher_id, project_json["course_id"]): + return {"message":"You are not the teacher of this course"}, 403 + filename = None if "assignment_file" in request.files: file = request.files["assignment_file"] From a524f9b5ed623f9b8d78b9ddf7ba0507768cea1d Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Sat, 4 May 2024 16:04:32 +0200 Subject: [PATCH 319/377] project form upload in user documentation (#250) * project form upload * added dutch translation * PR review and styling * siebe pr * aron pr * aron pr --- documentation/documentation/.gitignore | 20 + documentation/documentation/README.md | 41 + documentation/documentation/babel.config.js | 3 + .../docs/evaluators/_category_.json | 8 + .../docs/evaluators/custom_evaluator.md | 1 + .../docs/evaluators/general_evaluator.md | 1 + .../docs/evaluators/python_evaluator.md | 13 + documentation/documentation/docs/intro.md | 7 + .../docs/projectform/_category_.json | 8 + .../documentation/docs/projectform/image.png | Bin 0 -> 2313 bytes .../docs/projectform/project_upload_form.md | 34 + .../documentation/docusaurus.config.ts | 98 + .../current/evaluators/_category_.json | 8 + .../current/evaluators/custom_evaluator.md | 1 + .../current/evaluators/general_evaluator.md | 1 + .../current/evaluators/python_evaluator.md | 13 + .../current/intro.md | 47 + .../projectform/project_upload_form.md | 31 + documentation/documentation/package-lock.json | 14626 ++++++++++++++++ documentation/documentation/package.json | 47 + documentation/documentation/sidebars.ts | 31 + .../src/components/HomepageFeatures/index.tsx | 70 + .../HomepageFeatures/styles.module.css | 11 + .../documentation/src/css/custom.css | 30 + .../documentation/src/pages/index.module.css | 23 + .../documentation/src/pages/index.tsx | 43 + documentation/documentation/static/.nojekyll | 0 .../documentation/static/img/logo_app.png | Bin 0 -> 704 bytes .../documentation/static/img/logo_ugent.png | Bin 0 -> 6422 bytes .../static/img/project_form_1.png | Bin 0 -> 10483 bytes .../static/img/project_form_2.png | Bin 0 -> 31455 bytes .../static/img/project_upload_form_3.png | Bin 0 -> 3966 bytes .../static/img/project_upload_form_4.png | Bin 0 -> 23502 bytes .../static/img/project_upload_form_5.png | Bin 0 -> 27294 bytes .../static/img/project_upload_form_6.png | Bin 0 -> 18312 bytes .../static/img/project_upload_form_7.png | Bin 0 -> 2313 bytes .../static/img/undraw_docusaurus_mountain.svg | 171 + .../static/img/undraw_docusaurus_react.svg | 170 + .../static/img/undraw_docusaurus_tree.svg | 40 + documentation/documentation/tsconfig.json | 7 + 40 files changed, 15604 insertions(+) create mode 100644 documentation/documentation/.gitignore create mode 100644 documentation/documentation/README.md create mode 100644 documentation/documentation/babel.config.js create mode 100644 documentation/documentation/docs/evaluators/_category_.json create mode 100644 documentation/documentation/docs/evaluators/custom_evaluator.md create mode 100644 documentation/documentation/docs/evaluators/general_evaluator.md create mode 100644 documentation/documentation/docs/evaluators/python_evaluator.md create mode 100644 documentation/documentation/docs/intro.md create mode 100644 documentation/documentation/docs/projectform/_category_.json create mode 100644 documentation/documentation/docs/projectform/image.png create mode 100644 documentation/documentation/docs/projectform/project_upload_form.md create mode 100644 documentation/documentation/docusaurus.config.ts create mode 100644 documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json create mode 100644 documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md create mode 100644 documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md create mode 100644 documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md create mode 100644 documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md create mode 100644 documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md create mode 100644 documentation/documentation/package-lock.json create mode 100644 documentation/documentation/package.json create mode 100644 documentation/documentation/sidebars.ts create mode 100644 documentation/documentation/src/components/HomepageFeatures/index.tsx create mode 100644 documentation/documentation/src/components/HomepageFeatures/styles.module.css create mode 100644 documentation/documentation/src/css/custom.css create mode 100644 documentation/documentation/src/pages/index.module.css create mode 100644 documentation/documentation/src/pages/index.tsx create mode 100644 documentation/documentation/static/.nojekyll create mode 100644 documentation/documentation/static/img/logo_app.png create mode 100644 documentation/documentation/static/img/logo_ugent.png create mode 100644 documentation/documentation/static/img/project_form_1.png create mode 100644 documentation/documentation/static/img/project_form_2.png create mode 100644 documentation/documentation/static/img/project_upload_form_3.png create mode 100644 documentation/documentation/static/img/project_upload_form_4.png create mode 100644 documentation/documentation/static/img/project_upload_form_5.png create mode 100644 documentation/documentation/static/img/project_upload_form_6.png create mode 100644 documentation/documentation/static/img/project_upload_form_7.png create mode 100644 documentation/documentation/static/img/undraw_docusaurus_mountain.svg create mode 100644 documentation/documentation/static/img/undraw_docusaurus_react.svg create mode 100644 documentation/documentation/static/img/undraw_docusaurus_tree.svg create mode 100644 documentation/documentation/tsconfig.json diff --git a/documentation/documentation/.gitignore b/documentation/documentation/.gitignore new file mode 100644 index 00000000..b2d6de30 --- /dev/null +++ b/documentation/documentation/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +/node_modules + +# Production +/build + +# Generated files +.docusaurus +.cache-loader + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/documentation/documentation/README.md b/documentation/documentation/README.md new file mode 100644 index 00000000..0c6c2c27 --- /dev/null +++ b/documentation/documentation/README.md @@ -0,0 +1,41 @@ +# Website + +This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. + +### Installation + +``` +$ yarn +``` + +### Local Development + +``` +$ yarn start +``` + +This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. + +### Build + +``` +$ yarn build +``` + +This command generates static content into the `build` directory and can be served using any static contents hosting service. + +### Deployment + +Using SSH: + +``` +$ USE_SSH=true yarn deploy +``` + +Not using SSH: + +``` +$ GIT_USER= yarn deploy +``` + +If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. diff --git a/documentation/documentation/babel.config.js b/documentation/documentation/babel.config.js new file mode 100644 index 00000000..e00595da --- /dev/null +++ b/documentation/documentation/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [require.resolve('@docusaurus/core/lib/babel/preset')], +}; diff --git a/documentation/documentation/docs/evaluators/_category_.json b/documentation/documentation/docs/evaluators/_category_.json new file mode 100644 index 00000000..b5b1ee5e --- /dev/null +++ b/documentation/documentation/docs/evaluators/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Evaluators", + "position": 3, + "link": { + "type": "generated-index", + "description": "Choose an evaluator of which you need to learn the usage." + } +} \ No newline at end of file diff --git a/documentation/documentation/docs/evaluators/custom_evaluator.md b/documentation/documentation/docs/evaluators/custom_evaluator.md new file mode 100644 index 00000000..d7dd2552 --- /dev/null +++ b/documentation/documentation/docs/evaluators/custom_evaluator.md @@ -0,0 +1 @@ +# Custom evaluator \ No newline at end of file diff --git a/documentation/documentation/docs/evaluators/general_evaluator.md b/documentation/documentation/docs/evaluators/general_evaluator.md new file mode 100644 index 00000000..eb8ccedd --- /dev/null +++ b/documentation/documentation/docs/evaluators/general_evaluator.md @@ -0,0 +1 @@ +# General evaluator \ No newline at end of file diff --git a/documentation/documentation/docs/evaluators/python_evaluator.md b/documentation/documentation/docs/evaluators/python_evaluator.md new file mode 100644 index 00000000..c50653c8 --- /dev/null +++ b/documentation/documentation/docs/evaluators/python_evaluator.md @@ -0,0 +1,13 @@ +# Python evaluator +## General usage +This evaluator is responsible for running and executing tests on a student's Python code. + +## Structure +When submitting the project a teacher can add a requirements manifest `req-manifest.txt`, this way only the packages in the requirements file are usable on the evaluator. + +When no manifest is present, students are able to install their own depedencies with a `requirements.txt` and a `dev-requirements.txt`. +Or the teacher can add a `requirements.txt` if they want to pre install dependencies that a are present for testing the project. + +## Running tests +When a `run_tests.sh` is present in the project assignment files, it will be run when the student is submitting their code. +When running tests, it's important to note that the root of the student's submission will be `/submission`. diff --git a/documentation/documentation/docs/intro.md b/documentation/documentation/docs/intro.md new file mode 100644 index 00000000..7f0840ef --- /dev/null +++ b/documentation/documentation/docs/intro.md @@ -0,0 +1,7 @@ +--- +sidebar_position: 1 +--- + +# Project user guide + +If you need help using project Péristeronas you can read the user guide below. diff --git a/documentation/documentation/docs/projectform/_category_.json b/documentation/documentation/docs/projectform/_category_.json new file mode 100644 index 00000000..79ce8b28 --- /dev/null +++ b/documentation/documentation/docs/projectform/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Project upload form", + "position": 4, + "link": { + "type": "generated-index", + "description": "User guide on how to upload a new project" + } +} \ No newline at end of file diff --git a/documentation/documentation/docs/projectform/image.png b/documentation/documentation/docs/projectform/image.png new file mode 100644 index 0000000000000000000000000000000000000000..a0c70ee8b1c258a172da803d792ea9072893b45c GIT binary patch literal 2313 zcmbW3`9Bj39LFa;EK#mP?vkVA7|XRNL*&ec97|*7XmiXJb3M*I5h^0bgeP)Dk7GGQ z=GsJZBpM+s$0JO`uxHPo@I2qw>;3({zOUE!_4@wu`5u4R+1wQ1JI4nA00b<|P3$>w zo`W-xhvP3u?;<(jc(Abr9LTXKAd0}rB|=Q^gunvOA>l}z7r@&;0PA%n*c0dFxCyBnP zW9@xLl)|LDmeh~i>(X%f~%p%e$Y1`xVUIB#Z0IM4Hc=%+FO zH4YG&^2WyOJF3rq6!*)ysfo#Cq?DADSe@=Mcf4v;ESu4%7qzQ#w>D>f-b0m2VPq-@ zOp|;A0?J>$OwtQpQts8R(tKXF6?GKA;yuLx-V$z&%<|{4EQfNvlHK?85Y|S%oU7XT_v$t zETDP10MQAhr?y#f7NNTFX@#bzyJXWNEP zC+7F&Q!jnuughK65I^4eBV?Y*Qn_-aXFZI&UEb`xP+ndhp~nL=O%mvswG9moRZO{t zD%Cf^As#Hp7@w8=Y&n9kDb-l|71FKYjw&FVTR^H#vsmarZG7UTzr} zP;Xk=$*l}X1cSl5QSV}?3gOn#hO%AvGC7T5FcA_K27_sRg36Faq&-~Drn~yjMNixh z2}N`&W{BWPKPRbYs%M*2!T2eqa}_NA%(~j>{4=Af^Q_ojog?jRUa>;taJtj3ledhi ztny300q-XEkV+bt@QB!Coyy=>^`XV}Law{lf`|tvV9M@qvW6Xp(e^kRg1wa8dFq$c zfs#_|$y{yh)ss1D-o-Y{rV_Z&QNb>g+< zhq#2zy{N2b&+OgZZ8gNnt2^g9@+<4h4ktrMH}$aA?-8IjPck_+Z#Np6CUIF7dFbR6 zyzyO`MU2dgR${dkRh4JA+p?30tuiRYy1t=Ktkh#MQr+P<2}~SW4mZY#!X(Uio zuk7fBAdMO12+NUrd?Z#oxaC#Bw53|$ueGF9s;){0&2-^@fROdquGfGG-Lf3K{}hnx z{9_;>rX#k~Yh`7H2az(np(F@YHhJotwC&7F7>~A@U=YEy`W-RIHd(fL$F=CaL7Htp zCHz~Hy@aE@Y5MQ!K;Z(t;AA43Cn75P4g0Ol+Mil_H(W(>b&qYwda&sfu(jkXx$!5%atcux zf?zsnxr&O4W*UUbyR(;~t)ySn!&7zWlv@rCXFB6F(@hdPFN&@5K7klY`JmVE5#1<# z=ci`=#R*X@bgOJMGMu>BJ~rnO5BpFXjH9PyKxrUY+o-aS5oJUq+R?x7LBuL^@?h|G zOD$88SHli-;mn**sf4D$Wt|*|fz3g+z;{s4trJNb$-uP(xrlJM$w&# z6&u{C8`S+I)I$4vOj--tjCnj{ zhjtKACTrsqb2Pq0{sC2&o)__QuqtSb2ah8ltsCKE?B;x8o5i5(rbWT9blZ`nY_O!| zyDgw6`zM4I2C?uQ)^#I3|N1seg&BGCs3aQHYpwvA*Fu8G<_frR`HQx@xiTJ(Y@xhE z7PJ0dSG*7nU1DIPl%&ho;1ml^-qx}tYU7&SFq3cO*FjGDW?$PPv^a~q+NF^~uP@uj zo+~SeKX0mQTv=Hes9#9-_-l*??iS+bfqj;@P8Sp{5m$={w~`Yy$1l~_)TpgvCMPDs z1`v8>FoH(|g+duafCun5YZb)#ITL@HF@x~j#{l)6FF4ETm0m2W1o9+QNVclS@?R3? zFC#iJ_M)6MCF6~=RByvz37=Ol3IYXFd+b{af9Mw4`1$$Oc_>=h6P|#d)OG<=x)ZyR z?U$emPwT*>E;{NFflIXHzfosMR*hkfzbY*n@2t}|$)TF!@%Vo))Y!~SI(3o4&`8S~ zmyd+VxVX3w%u4rXC+?B3B9F{`eSNzYHc^qfW|S3X-ZAn*kc+G9=n*1RGljbNeqqxN z4$t~gEUvN&6>XM4?bKBCe=wGRD1QBa@GVwPz7IX|clJlln*y*fwK1tNz8C)wq{cxb literal 0 HcmV?d00001 diff --git a/documentation/documentation/docs/projectform/project_upload_form.md b/documentation/documentation/docs/projectform/project_upload_form.md new file mode 100644 index 00000000..670cadd2 --- /dev/null +++ b/documentation/documentation/docs/projectform/project_upload_form.md @@ -0,0 +1,34 @@ +# Project upload guide +A teacher can upload a new project of their course by going to the url ```projects/create``` where they will need to fill out data corresponding to the project they want to upload. +![Projectform](/img/project_form_1.png) + +For the first 3 fields of the form, you just need to fill in the title you want for the project, the description and select the course you want the project to be for. The course selection is in a dropdown menu where you can see all the courses that you are the teacher of. + +![Projectform](/img/project_form_2.png) + +Next there's a calender where you need to choose deadlines of the project, every deadline can have a description and a timestamp. +As seen in the image above you'll see a table of every deadline you added. If you want to remove a deadline, simply click on the calender date itself and click the remove icon. + +![Projectform](/img/project_upload_form_3.png) + +Then there is a checkbox that contains the value visible for students, this dictates if a project should be immediately visible on upload or not. +When the box is ticked, the value will be set to true and the project will be visible on upload. + +![Projectform](/img/project_upload_form_4.png) + +To upload assignment files you can use the given file uploader. Files must be uploaded as a .zip file. +You can drag & drop your files or click the button and when the files are uploaded, you get a table that shows which files are in the selected .zip file. + +![Projectform](/img/project_upload_form_5.png) + +You can specify file restrictions for submissions of students with the above part of the form. +There you can choose what you need the filename to start with, for example. + +![Projectform](/img/project_upload_form_6.png) + +If you're familiar with regex you can also use the advanced mode to specify file restrictions. If you don't know how to use regex, but are still interested in using this functionality, you can consult the following [cheatsheet](https://cheatography.com/davechild/cheat-sheets/regular-expressions/). + +Finally there's the dropdown menu for the runner. +The explanation for this menu can be found in this [section](/docs/category/evaluators) + +![Runner](/img/project_upload_form_7.png) diff --git a/documentation/documentation/docusaurus.config.ts b/documentation/documentation/docusaurus.config.ts new file mode 100644 index 00000000..f0fc50a8 --- /dev/null +++ b/documentation/documentation/docusaurus.config.ts @@ -0,0 +1,98 @@ +import {themes as prismThemes} from 'prism-react-renderer'; +import type {Config} from '@docusaurus/types'; +import type * as Preset from '@docusaurus/preset-classic'; + +const config: Config = { + title: 'Project péristeronas', + tagline: 'An assignment manager for teacher', + favicon: 'img/logo_ugent.png', + + // Set the production url of your site here + url: 'https://sel2-3.ugent.be/', + // Set the // pathname under which your site is served + // For GitHub pages deployment, it is often '//' + baseUrl: '/', + + // GitHub pages deployment config. + // If you aren't using GitHub pages, you don't need these. + organizationName: 'Ugent', // Usually your GitHub org/user name. + projectName: 'péristeronas', // Usually your repo name. + + onBrokenLinks: 'throw', + onBrokenMarkdownLinks: 'warn', + + // Even if you don't use internationalization, you can use this field to set + // useful metadata like html lang. For example, if your site is Chinese, you + // may want to replace "en" with "zh-Hans". + i18n: { + defaultLocale: 'en', + locales: ['en', 'nl'], + }, + + presets: [ + [ + 'classic', + { + docs: { + sidebarPath: './sidebars.ts', + // Please change this to your repo. + // Remove this to remove the "edit this page" links. + editUrl: + 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/', + }, + theme: { + customCss: './src/css/custom.css', + }, + } satisfies Preset.Options, + ], + ], + + themeConfig: { + // Replace with your project's social card + image: 'img/docusaurus-social-card.jpg', + navbar: { + title: 'Project péristeronas', + logo: { + alt: 'Project péristorenas', + src: 'img/logo_app.png', + }, + items: [ + { + type: 'docSidebar', + sidebarId: 'tutorialSidebar', + position: 'left', + label: 'User guide', + }, + { + href: 'https://github.com/facebook/docusaurus', + label: 'GitHub', + position: 'right', + }, + { + type: 'localeDropdown', + }, + ], + }, + footer: { + style: 'dark', + links: [ + { + title: 'Docs', + items: [ + { + label: 'Tutorial', + to: '/docs/intro', + }, + ], + }, + ], + copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`, + }, + prism: { + theme: prismThemes.github, + darkTheme: prismThemes.dracula, + }, + } satisfies Preset.ThemeConfig, +}; + +export default config; diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json new file mode 100644 index 00000000..6efbdbff --- /dev/null +++ b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Evaluators", + "position": 3, + "link": { + "type": "generated-index", + "description": "Kies een evaluator die je wilt leren gebruiken." + } +} \ No newline at end of file diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md new file mode 100644 index 00000000..d7dd2552 --- /dev/null +++ b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md @@ -0,0 +1 @@ +# Custom evaluator \ No newline at end of file diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md new file mode 100644 index 00000000..eb8ccedd --- /dev/null +++ b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md @@ -0,0 +1 @@ +# General evaluator \ No newline at end of file diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md new file mode 100644 index 00000000..c50653c8 --- /dev/null +++ b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md @@ -0,0 +1,13 @@ +# Python evaluator +## General usage +This evaluator is responsible for running and executing tests on a student's Python code. + +## Structure +When submitting the project a teacher can add a requirements manifest `req-manifest.txt`, this way only the packages in the requirements file are usable on the evaluator. + +When no manifest is present, students are able to install their own depedencies with a `requirements.txt` and a `dev-requirements.txt`. +Or the teacher can add a `requirements.txt` if they want to pre install dependencies that a are present for testing the project. + +## Running tests +When a `run_tests.sh` is present in the project assignment files, it will be run when the student is submitting their code. +When running tests, it's important to note that the root of the student's submission will be `/submission`. diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md new file mode 100644 index 00000000..7c253496 --- /dev/null +++ b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md @@ -0,0 +1,47 @@ +--- +sidebar_position: 1 +--- + +# Project user guide + +If you need help using the you can read the user guide below. + +## Getting Started + +Get started by **creating a new site**. + +Or **try Docusaurus immediately** with **[docusaurus.new](https://docusaurus.new)**. + +### What you'll need + +- [Node.js](https://nodejs.org/en/download/) version 18.0 or above: + - When installing Node.js, you are recommended to check all checkboxes related to dependencies. + +## Generate a new site + +Generate a new Docusaurus site using the **classic template**. + +The classic template will automatically be added to your project after you run the command: + +```bash +npm init docusaurus@latest my-website classic +``` + +You can type this command into Command Prompt, Powershell, Terminal, or any other integrated terminal of your code editor. + +The command also installs all necessary dependencies you need to run Docusaurus. + +## Start your site + +Run the development server: + +```bash +cd my-website +npm run start +``` + +The `cd` command changes the directory you're working with. In order to work with your newly created Docusaurus site, you'll need to navigate the terminal there. + +The `npm run start` command builds your website locally and serves it through a development server, ready for you to view at http://localhost:3000/. + +Open `docs/intro.md` (this page) and edit some lines: the site **reloads automatically** and displays your changes. diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md new file mode 100644 index 00000000..f8d6e027 --- /dev/null +++ b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md @@ -0,0 +1,31 @@ +# Project upload handleiding +Een leraar kan een nieuw project van zijn cursus uploaden door naar de url `projects/create` te gaan, waar hij gegevens moet invullen die overeenkomen met het project dat hij wil uploaden. + +![Projectformulier](/img/project_form_1.png) + +Voor de eerste 3 velden van de formulier, hoef je alleen maar de titel in te vullen die je voor het project wilt, de beschrijving en de cursus te selecteren waarvoor je het project wilt. De cursusselectie bevindt zich in een vervolgkeuzemenu waarin je alle cursussen ziet waarvan je leraar bent. + +![Projectformulier](/img/project_form_2.png) + +Vervolgens is er een kalender waar je de deadlines van het project moet kiezen, elke deadline kan een beschrijving en een tijdstempel hebben. Zoals te zien is in de afbeelding hierboven, zie je een tabel van elke deadline die je hebt toegevoegd. Als je een deadline wilt verwijderen, klik dan gewoon op de datum in de kalender en klik op het verwijdersymbool. + +![Projectformulier](/img/project_upload_form_3.png) + +Daarna is er een selectievakje dat de waarde bevat die zichtbaar is voor studenten, dit bepaalt of een project direct zichtbaar moet zijn bij het uploaden of niet. +Als de box aangeduid is, zal deze waarde op true staat en zal het project direct zichtbaar worden na de upload. + +![Projectformulier](/img/project_upload_form_4.png) + +Om opdrachtbestanden te uploaden, kun je de gegeven bestandsuploader gebruiken, de bestanden moeten geüpload worden als een .zip-bestand. Je kunt je bestanden slepen en neerzetten of op de knop klikken en wanneer de bestanden geüpload zijn, krijg je een tabel die een overzicht geeft van welke bestanden in het zipbestand zitten. + +![Projectformulier](/img/project_upload_form_5.png) + +Je kunt bestandsbeperkingen specificeren voor de inzendingen van studenten met het bovenstaande deel van het formulier. Daar kun je kiezen met welke bestandsnaam je wilt dat het bestand begint, bijvoorbeeld. + +![Projectformulier](/img/project_upload_form_6.png) + +Als je bekend bent met regex, kun je ook naar de geavanceerde modus gaan om bestandsbeperkingen te specificeren. Als je niet weet hoe je regex moet gebruiken maar toch interesse hebt om deze functionaliteit te gebruiken, kun je de volgende [cheatsheet](https://cheatography.com/davechild/cheat-sheets/regular-expressions/) raadplegen. + +Tot slot is er het vervolgkeuzemenu voor de runner. Maar die worden uitgelegd in deze [sectie](/docs/category/evaluators). + +![Runner](/img/project_upload_form_7.png) diff --git a/documentation/documentation/package-lock.json b/documentation/documentation/package-lock.json new file mode 100644 index 00000000..e8e731d0 --- /dev/null +++ b/documentation/documentation/package-lock.json @@ -0,0 +1,14626 @@ +{ + "name": "documentation", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "documentation", + "version": "0.0.0", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/preset-classic": "3.2.1", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "prism-react-renderer": "^2.3.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "3.2.1", + "@docusaurus/tsconfig": "3.2.1", + "@docusaurus/types": "3.2.1", + "typescript": "~5.2.2" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz", + "integrity": "sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.9.3", + "@algolia/autocomplete-shared": "1.9.3" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz", + "integrity": "sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==", + "dependencies": { + "@algolia/autocomplete-shared": "1.9.3" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz", + "integrity": "sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==", + "dependencies": { + "@algolia/autocomplete-shared": "1.9.3" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz", + "integrity": "sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/cache-browser-local-storage": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.3.tgz", + "integrity": "sha512-vRHXYCpPlTDE7i6UOy2xE03zHF2C8MEFjPN2v7fRbqVpcOvAUQK81x3Kc21xyb5aSIpYCjWCZbYZuz8Glyzyyg==", + "dependencies": { + "@algolia/cache-common": "4.23.3" + } + }, + "node_modules/@algolia/cache-common": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.23.3.tgz", + "integrity": "sha512-h9XcNI6lxYStaw32pHpB1TMm0RuxphF+Ik4o7tcQiodEdpKK+wKufY6QXtba7t3k8eseirEMVB83uFFF3Nu54A==" + }, + "node_modules/@algolia/cache-in-memory": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.23.3.tgz", + "integrity": "sha512-yvpbuUXg/+0rbcagxNT7un0eo3czx2Uf0y4eiR4z4SD7SiptwYTpbuS0IHxcLHG3lq22ukx1T6Kjtk/rT+mqNg==", + "dependencies": { + "@algolia/cache-common": "4.23.3" + } + }, + "node_modules/@algolia/client-account": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.23.3.tgz", + "integrity": "sha512-hpa6S5d7iQmretHHF40QGq6hz0anWEHGlULcTIT9tbUssWUriN9AUXIFQ8Ei4w9azD0hc1rUok9/DeQQobhQMA==", + "dependencies": { + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/transporter": "4.23.3" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.23.3.tgz", + "integrity": "sha512-LBsEARGS9cj8VkTAVEZphjxTjMVCci+zIIiRhpFun9jGDUlS1XmhCW7CTrnaWeIuCQS/2iPyRqSy1nXPjcBLRA==", + "dependencies": { + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" + } + }, + "node_modules/@algolia/client-common": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.23.3.tgz", + "integrity": "sha512-l6EiPxdAlg8CYhroqS5ybfIczsGUIAC47slLPOMDeKSVXYG1n0qGiz4RjAHLw2aD0xzh2EXZ7aRguPfz7UKDKw==", + "dependencies": { + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.23.3.tgz", + "integrity": "sha512-3E3yF3Ocr1tB/xOZiuC3doHQBQ2zu2MPTYZ0d4lpfWads2WTKG7ZzmGnsHmm63RflvDeLK/UVx7j2b3QuwKQ2g==", + "dependencies": { + "@algolia/client-common": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" + } + }, + "node_modules/@algolia/client-search": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.23.3.tgz", + "integrity": "sha512-P4VAKFHqU0wx9O+q29Q8YVuaowaZ5EM77rxfmGnkHUJggh28useXQdopokgwMeYw2XUht49WX5RcTQ40rZIabw==", + "dependencies": { + "@algolia/client-common": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" + } + }, + "node_modules/@algolia/events": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", + "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" + }, + "node_modules/@algolia/logger-common": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.23.3.tgz", + "integrity": "sha512-y9kBtmJwiZ9ZZ+1Ek66P0M68mHQzKRxkW5kAAXYN/rdzgDN0d2COsViEFufxJ0pb45K4FRcfC7+33YB4BLrZ+g==" + }, + "node_modules/@algolia/logger-console": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.23.3.tgz", + "integrity": "sha512-8xoiseoWDKuCVnWP8jHthgaeobDLolh00KJAdMe9XPrWPuf1by732jSpgy2BlsLTaT9m32pHI8CRfrOqQzHv3A==", + "dependencies": { + "@algolia/logger-common": "4.23.3" + } + }, + "node_modules/@algolia/recommend": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.23.3.tgz", + "integrity": "sha512-9fK4nXZF0bFkdcLBRDexsnGzVmu4TSYZqxdpgBW2tEyfuSSY54D4qSRkLmNkrrz4YFvdh2GM1gA8vSsnZPR73w==", + "dependencies": { + "@algolia/cache-browser-local-storage": "4.23.3", + "@algolia/cache-common": "4.23.3", + "@algolia/cache-in-memory": "4.23.3", + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/logger-console": "4.23.3", + "@algolia/requester-browser-xhr": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/requester-node-http": "4.23.3", + "@algolia/transporter": "4.23.3" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.3.tgz", + "integrity": "sha512-jDWGIQ96BhXbmONAQsasIpTYWslyjkiGu0Quydjlowe+ciqySpiDUrJHERIRfELE5+wFc7hc1Q5hqjGoV7yghw==", + "dependencies": { + "@algolia/requester-common": "4.23.3" + } + }, + "node_modules/@algolia/requester-common": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.23.3.tgz", + "integrity": "sha512-xloIdr/bedtYEGcXCiF2muajyvRhwop4cMZo+K2qzNht0CMzlRkm8YsDdj5IaBhshqfgmBb3rTg4sL4/PpvLYw==" + }, + "node_modules/@algolia/requester-node-http": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.23.3.tgz", + "integrity": "sha512-zgu++8Uj03IWDEJM3fuNl34s746JnZOWn1Uz5taV1dFyJhVM/kTNw9Ik7YJWiUNHJQXcaD8IXD1eCb0nq/aByA==", + "dependencies": { + "@algolia/requester-common": "4.23.3" + } + }, + "node_modules/@algolia/transporter": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.23.3.tgz", + "integrity": "sha512-Wjl5gttqnf/gQKJA+dafnD0Y6Yw97yvfY8R9h0dQltX1GXTgNs1zWgvtWW0tHl1EgMdhAyw189uWiZMnL3QebQ==", + "dependencies": { + "@algolia/cache-common": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/requester-common": "4.23.3" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", + "integrity": "sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==", + "dependencies": { + "@babel/highlight": "^7.24.2", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.4.tgz", + "integrity": "sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.4", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "dependencies": { + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", + "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.4.tgz", + "integrity": "sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-replace-supers": "^7.24.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", + "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "regexpu-core": "^5.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", + "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", + "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "dependencies": { + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", + "integrity": "sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==", + "dependencies": { + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", + "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", + "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", + "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-wrap-function": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.1.tgz", + "integrity": "sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", + "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", + "integrity": "sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", + "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "dependencies": { + "@babel/helper-function-name": "^7.22.5", + "@babel/template": "^7.22.15", + "@babel/types": "^7.22.19" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", + "integrity": "sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/parser": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.4.tgz", + "integrity": "sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.4.tgz", + "integrity": "sha512-qpl6vOOEEzTLLcsuqYYo8yDtrTocmu2xkGvgNebvPjT9DTtfFYGmgDqY+rBYXNlqL4s9qLDn6xkrJv4RxAPiTA==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.1.tgz", + "integrity": "sha512-y4HqEnkelJIOQGd+3g1bTeKsA5c6qM7eOn7VggGVbBc0y8MLSKHacwcIE2PplNlQSj0PqS9rrXL/nkPVK+kUNg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.1.tgz", + "integrity": "sha512-Hj791Ii4ci8HqnaKHAlLNs+zaLXb0EzSDhiAWp5VNlyvCNymYfacs64pxTxbH1znW/NcArSmwpmG9IKE/TUVVQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-transform-optional-chaining": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.1.tgz", + "integrity": "sha512-m9m/fXsXLiHfwdgydIFnpk+7jlVbnvlK5B2EKiPdLUb6WX654ZaaEWJUjk8TftRbZpK0XibovlLWX4KIZhV6jw==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.1.tgz", + "integrity": "sha512-IuwnI5XnuF189t91XbxmXeCDz3qs6iDRO7GJ++wcfgeXNs/8FmIlKcpDSXNVyuLQxlwvskmI3Ct73wUODkJBlQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.1.tgz", + "integrity": "sha512-zhQTMH0X2nVLnb04tz+s7AMuasX8U0FnpE+nHTOhSOINjWMnopoZTxtIKsd45n4GQ/HIZLyfIpoul8e2m0DnRA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", + "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz", + "integrity": "sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.1.tgz", + "integrity": "sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.24.3.tgz", + "integrity": "sha512-Qe26CMYVjpQxJ8zxM1340JFNjZaF+ISWpr1Kt/jGo+ZTUzKkfw/pphEWbRCb+lmSM6k/TOgfYLvmbHkUQ0asIg==", + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-remap-async-to-generator": "^7.22.20", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.1.tgz", + "integrity": "sha512-AawPptitRXp1y0n4ilKcGbRYWfbbzFWz2NqNu7dacYDtFtz0CMjG64b3LQsb3KIgnf4/obcUL78hfaOS7iCUfw==", + "dependencies": { + "@babel/helper-module-imports": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-remap-async-to-generator": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.1.tgz", + "integrity": "sha512-TWWC18OShZutrv9C6mye1xwtam+uNi2bnTOCBUd5sZxyHOiWbU6ztSROofIMrK84uweEZC219POICK/sTYwfgg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.4.tgz", + "integrity": "sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.1.tgz", + "integrity": "sha512-OMLCXi0NqvJfORTaPQBwqLXHhb93wkBKZ4aNwMl6WtehO7ar+cmp+89iPEQPqxAnxsOKTaMcs3POz3rKayJ72g==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.4.tgz", + "integrity": "sha512-B8q7Pz870Hz/q9UgP8InNpY01CSLDSCyqX7zcRuv3FcPl87A2G17lASroHWaCtbdIcbYzOZ7kWmXFKbijMSmFg==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.24.4", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.1.tgz", + "integrity": "sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-replace-supers": "^7.24.1", + "@babel/helper-split-export-declaration": "^7.22.6", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.1.tgz", + "integrity": "sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/template": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.1.tgz", + "integrity": "sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.1.tgz", + "integrity": "sha512-p7uUxgSoZwZ2lPNMzUkqCts3xlp8n+o05ikjy7gbtFJSt9gdU88jAmtfmOxHM14noQXBxfgzf2yRWECiNVhTCw==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.1.tgz", + "integrity": "sha512-msyzuUnvsjsaSaocV6L7ErfNsa5nDWL1XKNnDePLgmz+WdU4w/J8+AxBMrWfi9m4IxfL5sZQKUPQKDQeeAT6lA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.1.tgz", + "integrity": "sha512-av2gdSTyXcJVdI+8aFZsCAtR29xJt0S5tas+Ef8NvBNmD1a+N/3ecMLeMBgfcK+xzsjdLDT6oHt+DFPyeqUbDA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.1.tgz", + "integrity": "sha512-U1yX13dVBSwS23DEAqU+Z/PkwE9/m7QQy8Y9/+Tdb8UWYaGNDYwTLi19wqIAiROr8sXVum9A/rtiH5H0boUcTw==", + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.1.tgz", + "integrity": "sha512-Ft38m/KFOyzKw2UaJFkWG9QnHPG/Q/2SkOrRk4pNBPg5IPZ+dOxcmkK5IyuBcxiNPyyYowPGUReyBvrvZs7IlQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.1.tgz", + "integrity": "sha512-OxBdcnF04bpdQdR3i4giHZNZQn7cm8RQKcSwA17wAAqEELo1ZOwp5FFgeptWUQXFyT9kwHo10aqqauYkRZPCAg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.1.tgz", + "integrity": "sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.1.tgz", + "integrity": "sha512-U7RMFmRvoasscrIFy5xA4gIp8iWnWubnKkKuUGJjsuOH7GfbMkB+XZzeslx2kLdEGdOJDamEmCqOks6e8nv8DQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.1.tgz", + "integrity": "sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.1.tgz", + "integrity": "sha512-OhN6J4Bpz+hIBqItTeWJujDOfNP+unqv/NJgyhlpSqgBTPm37KkMmZV6SYcOj+pnDbdcl1qRGV/ZiIjX9Iy34w==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.1.tgz", + "integrity": "sha512-4ojai0KysTWXzHseJKa1XPNXKRbuUrhkOPY4rEGeR+7ChlJVKxFa3H3Bz+7tWaGKgJAXUWKOGmltN+u9B3+CVg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.1.tgz", + "integrity": "sha512-lAxNHi4HVtjnHd5Rxg3D5t99Xm6H7b04hUS7EHIXcUl2EV4yl1gWdqZrNzXnSrHveL9qMdbODlLF55mvgjAfaQ==", + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.1.tgz", + "integrity": "sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==", + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-simple-access": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.1.tgz", + "integrity": "sha512-mqQ3Zh9vFO1Tpmlt8QPnbwGHzNz3lpNEMxQb1kAemn/erstyqw1r9KeOlOfo3y6xAnFEcOv2tSyrXfmMk+/YZA==", + "dependencies": { + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.1.tgz", + "integrity": "sha512-tuA3lpPj+5ITfcCluy6nWonSL7RvaG0AOTeAuvXqEKS34lnLzXpDb0dcP6K8jD0zWZFNDVly90AGFJPnm4fOYg==", + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", + "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.1.tgz", + "integrity": "sha512-/rurytBM34hYy0HKZQyA0nHbQgQNFm4Q/BOc9Hflxi2X3twRof7NaE5W46j4kQitm7SvACVRXsa6N/tSZxvPug==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.1.tgz", + "integrity": "sha512-iQ+caew8wRrhCikO5DrUYx0mrmdhkaELgFa+7baMcVuhxIkN7oxt06CZ51D65ugIb1UWRQ8oQe+HXAVM6qHFjw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.1.tgz", + "integrity": "sha512-7GAsGlK4cNL2OExJH1DzmDeKnRv/LXq0eLUSvudrehVA5Rgg4bIrqEUW29FbKMBRT0ztSqisv7kjP+XIC4ZMNw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.1.tgz", + "integrity": "sha512-XjD5f0YqOtebto4HGISLNfiNMTTs6tbkFf2TOqJlYKYmbo+mN9Dnpl4SRoofiziuOWMIyq3sZEUqLo3hLITFEA==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.1.tgz", + "integrity": "sha512-oKJqR3TeI5hSLRxudMjFQ9re9fBVUU0GICqM3J1mi8MqlhVr6hC/ZN4ttAyMuQR6EZZIY6h/exe5swqGNNIkWQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-replace-supers": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.1.tgz", + "integrity": "sha512-oBTH7oURV4Y+3EUrf6cWn1OHio3qG/PVwO5J03iSJmBg6m2EhKjkAu/xuaXaYwWW9miYtvbWv4LNf0AmR43LUA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.1.tgz", + "integrity": "sha512-n03wmDt+987qXwAgcBlnUUivrZBPZ8z1plL0YvgQalLm+ZE5BMhGm94jhxXtA1wzv1Cu2aaOv1BM9vbVttrzSg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.1.tgz", + "integrity": "sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.1.tgz", + "integrity": "sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.1.tgz", + "integrity": "sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.1", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.1.tgz", + "integrity": "sha512-LetvD7CrHmEx0G442gOomRr66d7q8HzzGGr4PMHGr+5YIm6++Yke+jxj246rpvsbyhJwCLxcTn6zW1P1BSenqA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.24.1.tgz", + "integrity": "sha512-QXp1U9x0R7tkiGB0FOk8o74jhnap0FlZ5gNkRIWdG3eP+SvMFg118e1zaWewDzgABb106QSKpVsD3Wgd8t6ifA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.1.tgz", + "integrity": "sha512-mvoQg2f9p2qlpDQRBC7M3c3XTr0k7cp/0+kFKKO/7Gtu0LSw16eKB+Fabe2bDT/UpsyasTBBkAnbdsLrkD5XMw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", + "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-jsx": "^7.23.3", + "@babel/types": "^7.23.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.22.5.tgz", + "integrity": "sha512-bDhuzwWMuInwCYeDeMzyi7TaBgRQei6DqxhbyniL7/VG4RSS7HtSL2QbY4eESy1KJqlWt8g3xeEBGPuo+XqC8A==", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.1.tgz", + "integrity": "sha512-+pWEAaDJvSm9aFvJNpLiM2+ktl2Sn2U5DdyiWdZBxmLc6+xGt88dvFqsHiAiDS+8WqUwbDfkKz9jRxK3M0k+kA==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.1.tgz", + "integrity": "sha512-sJwZBCzIBE4t+5Q4IGLaaun5ExVMRY0lYwos/jNecjMrVCygCdph3IKv0tkP5Fc87e/1+bebAmEAGBfnRD+cnw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.1.tgz", + "integrity": "sha512-JAclqStUfIwKN15HrsQADFgeZt+wexNQ0uLhuqvqAUFoqPMjEcFCYZBhq0LUdz6dZK/mD+rErhW71fbx8RYElg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.24.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.24.3.tgz", + "integrity": "sha512-J0BuRPNlNqlMTRJ72eVptpt9VcInbxO6iP3jaxr+1NPhC0UkKL+6oeX6VXMEYdADnuqmMmsBspt4d5w8Y/TCbQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.24.3", + "@babel/helper-plugin-utils": "^7.24.0", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.1", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.1.tgz", + "integrity": "sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.1.tgz", + "integrity": "sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.1.tgz", + "integrity": "sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.1.tgz", + "integrity": "sha512-WRkhROsNzriarqECASCNu/nojeXCDTE/F2HmRgOzi7NGvyfYGq1NEjKBK3ckLfRgGc6/lPAqP0vDOSw3YtG34g==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.1.tgz", + "integrity": "sha512-CBfU4l/A+KruSUoW+vTQthwcAdwuqbpRNB8HQKlZABwHRhsdHZ9fezp4Sn18PeAlYxTNiLMlx4xUBV3AWfg1BA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.4.tgz", + "integrity": "sha512-79t3CQ8+oBGk/80SQ8MN3Bs3obf83zJ0YZjDmDaEZN8MqhMI760apl5z6a20kFeMXBwJX99VpKT8CKxEBp5H1g==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.24.4", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/plugin-syntax-typescript": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.1.tgz", + "integrity": "sha512-RlkVIcWT4TLI96zM660S877E7beKlQw7Ig+wqkKBiWfj0zH5Q4h50q6er4wzZKRNSYpfo6ILJ+hrJAGSX2qcNw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.1.tgz", + "integrity": "sha512-Ss4VvlfYV5huWApFsF8/Sq0oXnGO+jB+rijFEFugTd3cwSObUSnUi88djgR5528Csl0uKlrI331kRqe56Ov2Ng==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.1.tgz", + "integrity": "sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.1.tgz", + "integrity": "sha512-fqj4WuzzS+ukpgerpAoOnMfQXwUHFxXUZUE84oL2Kao2N8uSlvcpnAidKASgsNgzZHBsHWvcm8s9FPWUhAb8fA==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.4.tgz", + "integrity": "sha512-7Kl6cSmYkak0FK/FXjSEnLJ1N9T/WA2RkMhu17gZ/dsxKJUuTYNIylahPTzqpLyJN4WhDif8X0XK1R8Wsguo/A==", + "dependencies": { + "@babel/compat-data": "^7.24.4", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.24.4", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.24.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.24.1", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.24.1", + "@babel/plugin-syntax-import-attributes": "^7.24.1", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.24.1", + "@babel/plugin-transform-async-generator-functions": "^7.24.3", + "@babel/plugin-transform-async-to-generator": "^7.24.1", + "@babel/plugin-transform-block-scoped-functions": "^7.24.1", + "@babel/plugin-transform-block-scoping": "^7.24.4", + "@babel/plugin-transform-class-properties": "^7.24.1", + "@babel/plugin-transform-class-static-block": "^7.24.4", + "@babel/plugin-transform-classes": "^7.24.1", + "@babel/plugin-transform-computed-properties": "^7.24.1", + "@babel/plugin-transform-destructuring": "^7.24.1", + "@babel/plugin-transform-dotall-regex": "^7.24.1", + "@babel/plugin-transform-duplicate-keys": "^7.24.1", + "@babel/plugin-transform-dynamic-import": "^7.24.1", + "@babel/plugin-transform-exponentiation-operator": "^7.24.1", + "@babel/plugin-transform-export-namespace-from": "^7.24.1", + "@babel/plugin-transform-for-of": "^7.24.1", + "@babel/plugin-transform-function-name": "^7.24.1", + "@babel/plugin-transform-json-strings": "^7.24.1", + "@babel/plugin-transform-literals": "^7.24.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.1", + "@babel/plugin-transform-member-expression-literals": "^7.24.1", + "@babel/plugin-transform-modules-amd": "^7.24.1", + "@babel/plugin-transform-modules-commonjs": "^7.24.1", + "@babel/plugin-transform-modules-systemjs": "^7.24.1", + "@babel/plugin-transform-modules-umd": "^7.24.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.24.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.1", + "@babel/plugin-transform-numeric-separator": "^7.24.1", + "@babel/plugin-transform-object-rest-spread": "^7.24.1", + "@babel/plugin-transform-object-super": "^7.24.1", + "@babel/plugin-transform-optional-catch-binding": "^7.24.1", + "@babel/plugin-transform-optional-chaining": "^7.24.1", + "@babel/plugin-transform-parameters": "^7.24.1", + "@babel/plugin-transform-private-methods": "^7.24.1", + "@babel/plugin-transform-private-property-in-object": "^7.24.1", + "@babel/plugin-transform-property-literals": "^7.24.1", + "@babel/plugin-transform-regenerator": "^7.24.1", + "@babel/plugin-transform-reserved-words": "^7.24.1", + "@babel/plugin-transform-shorthand-properties": "^7.24.1", + "@babel/plugin-transform-spread": "^7.24.1", + "@babel/plugin-transform-sticky-regex": "^7.24.1", + "@babel/plugin-transform-template-literals": "^7.24.1", + "@babel/plugin-transform-typeof-symbol": "^7.24.1", + "@babel/plugin-transform-unicode-escapes": "^7.24.1", + "@babel/plugin-transform-unicode-property-regex": "^7.24.1", + "@babel/plugin-transform-unicode-regex": "^7.24.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.24.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.4", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.31.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.1.tgz", + "integrity": "sha512-eFa8up2/8cZXLIpkafhaADTXSnl7IsUFCYenRWrARBz0/qZwcT0RBXpys0LJU4+WfPoF2ZG6ew6s2V6izMCwRA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-transform-react-display-name": "^7.24.1", + "@babel/plugin-transform-react-jsx": "^7.23.4", + "@babel/plugin-transform-react-jsx-development": "^7.22.5", + "@babel/plugin-transform-react-pure-annotations": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.1.tgz", + "integrity": "sha512-1DBaMmRDpuYQBPWD8Pf/WEwCrtgRHxsZnP4mIy9G/X+hFfbI47Q2G4t1Paakld84+qsk2fSsUPMKg71jkoOOaQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-syntax-jsx": "^7.24.1", + "@babel/plugin-transform-modules-commonjs": "^7.24.1", + "@babel/plugin-transform-typescript": "^7.24.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" + }, + "node_modules/@babel/runtime": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", + "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.24.4.tgz", + "integrity": "sha512-VOQOexSilscN24VEY810G/PqtpFvx/z6UqDIjIWbDe2368HhDLkYN5TYwaEz/+eRCUkhJ2WaNLLmQAlxzfWj4w==", + "dependencies": { + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.6.0.tgz", + "integrity": "sha512-+sbxb71sWre+PwDK7X2T8+bhS6clcVMLwBPznX45Qu6opJcgRjAp7gYSDzVFp187J+feSj5dNBN1mJoi6ckkUQ==" + }, + "node_modules/@docsearch/react": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.6.0.tgz", + "integrity": "sha512-HUFut4ztcVNmqy9gp/wxNbC7pTOHhgVVkHVGCACTuLhUKUhKAF9KYHJtMiLUJxEqiFLQiuri1fWF8zqwM/cu1w==", + "dependencies": { + "@algolia/autocomplete-core": "1.9.3", + "@algolia/autocomplete-preset-algolia": "1.9.3", + "@docsearch/css": "3.6.0", + "algoliasearch": "^4.19.1" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@docusaurus/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/core/-/core-3.2.1.tgz", + "integrity": "sha512-ZeMAqNvy0eBv2dThEeMuNzzuu+4thqMQakhxsgT5s02A8LqRcdkg+rbcnuNqUIpekQ4GRx3+M5nj0ODJhBXo9w==", + "dependencies": { + "@babel/core": "^7.23.3", + "@babel/generator": "^7.23.3", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.22.9", + "@babel/preset-env": "^7.22.9", + "@babel/preset-react": "^7.22.5", + "@babel/preset-typescript": "^7.22.5", + "@babel/runtime": "^7.22.6", + "@babel/runtime-corejs3": "^7.22.6", + "@babel/traverse": "^7.22.8", + "@docusaurus/cssnano-preset": "3.2.1", + "@docusaurus/logger": "3.2.1", + "@docusaurus/mdx-loader": "3.2.1", + "@docusaurus/react-loadable": "5.5.2", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "@svgr/webpack": "^6.5.1", + "autoprefixer": "^10.4.14", + "babel-loader": "^9.1.3", + "babel-plugin-dynamic-import-node": "^2.3.3", + "boxen": "^6.2.1", + "chalk": "^4.1.2", + "chokidar": "^3.5.3", + "clean-css": "^5.3.2", + "cli-table3": "^0.6.3", + "combine-promises": "^1.1.0", + "commander": "^5.1.0", + "copy-webpack-plugin": "^11.0.0", + "core-js": "^3.31.1", + "css-loader": "^6.8.1", + "css-minimizer-webpack-plugin": "^4.2.2", + "cssnano": "^5.1.15", + "del": "^6.1.1", + "detect-port": "^1.5.1", + "escape-html": "^1.0.3", + "eta": "^2.2.0", + "eval": "^0.1.8", + "file-loader": "^6.2.0", + "fs-extra": "^11.1.1", + "html-minifier-terser": "^7.2.0", + "html-tags": "^3.3.1", + "html-webpack-plugin": "^5.5.3", + "leven": "^3.1.0", + "lodash": "^4.17.21", + "mini-css-extract-plugin": "^2.7.6", + "p-map": "^4.0.0", + "postcss": "^8.4.26", + "postcss-loader": "^7.3.3", + "prompts": "^2.4.2", + "react-dev-utils": "^12.0.1", + "react-helmet-async": "^1.3.0", + "react-loadable": "npm:@docusaurus/react-loadable@5.5.2", + "react-loadable-ssr-addon-v5-slorber": "^1.0.1", + "react-router": "^5.3.4", + "react-router-config": "^5.1.1", + "react-router-dom": "^5.3.4", + "rtl-detect": "^1.0.4", + "semver": "^7.5.4", + "serve-handler": "^6.1.5", + "shelljs": "^0.8.5", + "terser-webpack-plugin": "^5.3.9", + "tslib": "^2.6.0", + "update-notifier": "^6.0.2", + "url-loader": "^4.1.1", + "webpack": "^5.88.1", + "webpack-bundle-analyzer": "^4.9.0", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0", + "webpackbar": "^5.0.2" + }, + "bin": { + "docusaurus": "bin/docusaurus.mjs" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/cssnano-preset": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/cssnano-preset/-/cssnano-preset-3.2.1.tgz", + "integrity": "sha512-wTL9KuSSbMJjKrfu385HZEzAoamUsbKqwscAQByZw4k6Ja/RWpqgVvt/CbAC+aYEH6inLzOt+MjuRwMOrD3VBA==", + "dependencies": { + "cssnano-preset-advanced": "^5.3.10", + "postcss": "^8.4.26", + "postcss-sort-media-queries": "^4.4.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@docusaurus/logger": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/logger/-/logger-3.2.1.tgz", + "integrity": "sha512-0voOKJCn9RaM3np6soqEfo7SsVvf2C+CDTWhW+H/1AyBhybASpExtDEz+7ECck9TwPzFQ5tt+I3zVugUJbJWDg==", + "dependencies": { + "chalk": "^4.1.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@docusaurus/mdx-loader": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/mdx-loader/-/mdx-loader-3.2.1.tgz", + "integrity": "sha512-Fs8tXhXKZjNkdGaOy1xSLXSwfjCMT73J3Zfrju2U16uGedRFRjgK0ojpK5tiC7TnunsL3tOFgp1BSMBRflX9gw==", + "dependencies": { + "@docusaurus/logger": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "@mdx-js/mdx": "^3.0.0", + "@slorber/remark-comment": "^1.0.0", + "escape-html": "^1.0.3", + "estree-util-value-to-estree": "^3.0.1", + "file-loader": "^6.2.0", + "fs-extra": "^11.1.1", + "image-size": "^1.0.2", + "mdast-util-mdx": "^3.0.0", + "mdast-util-to-string": "^4.0.0", + "rehype-raw": "^7.0.0", + "remark-directive": "^3.0.0", + "remark-emoji": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "stringify-object": "^3.3.0", + "tslib": "^2.6.0", + "unified": "^11.0.3", + "unist-util-visit": "^5.0.0", + "url-loader": "^4.1.1", + "vfile": "^6.0.1", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/module-type-aliases": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/module-type-aliases/-/module-type-aliases-3.2.1.tgz", + "integrity": "sha512-FyViV5TqhL1vsM7eh29nJ5NtbRE6Ra6LP1PDcPvhwPSlA7eiWGRKAn3jWwMUcmjkos5SYY+sr0/feCdbM3eQHQ==", + "dependencies": { + "@docusaurus/react-loadable": "5.5.2", + "@docusaurus/types": "3.2.1", + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router-config": "*", + "@types/react-router-dom": "*", + "react-helmet-async": "*", + "react-loadable": "npm:@docusaurus/react-loadable@5.5.2" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@docusaurus/plugin-content-blog": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.2.1.tgz", + "integrity": "sha512-lOx0JfhlGZoZu6pEJfeEpSISZR5dQbJGGvb42IP13G5YThNHhG9R9uoWuo4IOimPqBC7sHThdLA3VLevk61Fsw==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/logger": "3.2.1", + "@docusaurus/mdx-loader": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "cheerio": "^1.0.0-rc.12", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "reading-time": "^1.5.0", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-docs": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.2.1.tgz", + "integrity": "sha512-GHe5b/lCskAR8QVbfWAfPAApvRZgqk7FN3sOHgjCtjzQACZxkHmq6QqyqZ8Jp45V7lVck4wt2Xw2IzBJ7Cz3bA==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/logger": "3.2.1", + "@docusaurus/mdx-loader": "3.2.1", + "@docusaurus/module-type-aliases": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "@types/react-router-config": "^5.0.7", + "combine-promises": "^1.1.0", + "fs-extra": "^11.1.1", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-pages": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.2.1.tgz", + "integrity": "sha512-TOqVfMVTAHqWNEGM94Drz+PUpHDbwFy6ucHFgyTx9zJY7wPNSG5EN+rd/mU7OvAi26qpOn2o9xTdUmb28QLjEQ==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/mdx-loader": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/plugin-debug": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-debug/-/plugin-debug-3.2.1.tgz", + "integrity": "sha512-AMKq8NuUKf2sRpN1m/sIbqbRbnmk+rSA+8mNU1LNxEl9BW9F/Gng8m9HKlzeyMPrf5XidzS1jqkuTLDJ6KIrFw==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils": "3.2.1", + "fs-extra": "^11.1.1", + "react-json-view-lite": "^1.2.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-analytics": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.2.1.tgz", + "integrity": "sha512-/rJ+9u+Px0eTCiF4TNcNtj3kHf8cp6K1HCwOTdbsSlz6Xn21syZYcy+f1VM9wF6HrvUkXUcbM5TDCvg2IRL6bQ==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-gtag": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.2.1.tgz", + "integrity": "sha512-XtuJnlMvYfppeVdUyKiDIJAa/gTJKCQU92z8CLZZ9ibJdgVjFOLS10s0hIC0eL5z0U2u2loJz2rZ63HOkNHbBA==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "@types/gtag.js": "^0.0.12", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-tag-manager": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.2.1.tgz", + "integrity": "sha512-wiS/kE0Ny5pnjTxVCs8ljRnkL1RVMj59t6jmSsgEX7piDOoaXSMIUaoIt9ogS/v132uO0xEsxHstkRUZHQyPcQ==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/plugin-sitemap": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.2.1.tgz", + "integrity": "sha512-uWZ7AxzdeaQSTCwD2yZtOiEm9zyKU+wqCmi/Sf25kQQqqFSBZUStXfaQ8OHP9cecnw893ZpZ811rPhB/wfujJw==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/logger": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "fs-extra": "^11.1.1", + "sitemap": "^7.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/preset-classic": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/preset-classic/-/preset-classic-3.2.1.tgz", + "integrity": "sha512-E3OHSmttpEBcSMhfPBq3EJMBxZBM01W1rnaCUTXy9EHvkmB5AwgTfW1PwGAybPAX579ntE03R+2zmXdizWfKnQ==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/plugin-content-blog": "3.2.1", + "@docusaurus/plugin-content-docs": "3.2.1", + "@docusaurus/plugin-content-pages": "3.2.1", + "@docusaurus/plugin-debug": "3.2.1", + "@docusaurus/plugin-google-analytics": "3.2.1", + "@docusaurus/plugin-google-gtag": "3.2.1", + "@docusaurus/plugin-google-tag-manager": "3.2.1", + "@docusaurus/plugin-sitemap": "3.2.1", + "@docusaurus/theme-classic": "3.2.1", + "@docusaurus/theme-common": "3.2.1", + "@docusaurus/theme-search-algolia": "3.2.1", + "@docusaurus/types": "3.2.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/react-loadable": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", + "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "dependencies": { + "@types/react": "*", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@docusaurus/theme-classic": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-classic/-/theme-classic-3.2.1.tgz", + "integrity": "sha512-+vSbnQyoWjc6vRZi4vJO2dBU02wqzynsai15KK+FANZudrYaBHtkbLZAQhgmxzBGVpxzi87gRohlMm+5D8f4tA==", + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/mdx-loader": "3.2.1", + "@docusaurus/module-type-aliases": "3.2.1", + "@docusaurus/plugin-content-blog": "3.2.1", + "@docusaurus/plugin-content-docs": "3.2.1", + "@docusaurus/plugin-content-pages": "3.2.1", + "@docusaurus/theme-common": "3.2.1", + "@docusaurus/theme-translations": "3.2.1", + "@docusaurus/types": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "copy-text-to-clipboard": "^3.2.0", + "infima": "0.2.0-alpha.43", + "lodash": "^4.17.21", + "nprogress": "^0.2.0", + "postcss": "^8.4.26", + "prism-react-renderer": "^2.3.0", + "prismjs": "^1.29.0", + "react-router-dom": "^5.3.4", + "rtlcss": "^4.1.0", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-common": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-common/-/theme-common-3.2.1.tgz", + "integrity": "sha512-d+adiD7L9xv6EvfaAwUqdKf4orsM3jqgeqAM+HAjgL/Ux0GkVVnfKr+tsoe+4ow4rHe6NUt+nkkW8/K8dKdilA==", + "dependencies": { + "@docusaurus/mdx-loader": "3.2.1", + "@docusaurus/module-type-aliases": "3.2.1", + "@docusaurus/plugin-content-blog": "3.2.1", + "@docusaurus/plugin-content-docs": "3.2.1", + "@docusaurus/plugin-content-pages": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router-config": "*", + "clsx": "^2.0.0", + "parse-numeric-range": "^1.3.0", + "prism-react-renderer": "^2.3.0", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-search-algolia": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.2.1.tgz", + "integrity": "sha512-bzhCrpyXBXzeydNUH83II2akvFEGfhsNTPPWsk5N7e+odgQCQwoHhcF+2qILbQXjaoZ6B3c48hrvkyCpeyqGHw==", + "dependencies": { + "@docsearch/react": "^3.5.2", + "@docusaurus/core": "3.2.1", + "@docusaurus/logger": "3.2.1", + "@docusaurus/plugin-content-docs": "3.2.1", + "@docusaurus/theme-common": "3.2.1", + "@docusaurus/theme-translations": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-validation": "3.2.1", + "algoliasearch": "^4.18.0", + "algoliasearch-helper": "^3.13.3", + "clsx": "^2.0.0", + "eta": "^2.2.0", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/theme-translations": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.2.1.tgz", + "integrity": "sha512-jAUMkIkFfY+OAhJhv6mV8zlwY6J4AQxJPTgLdR2l+Otof9+QdJjHNh/ifVEu9q0lp3oSPlJj9l05AaP7Ref+cg==", + "dependencies": { + "fs-extra": "^11.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@docusaurus/tsconfig": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/tsconfig/-/tsconfig-3.2.1.tgz", + "integrity": "sha512-+biUwtsYW3oChLxYezzA+NIgS3Q9KDRl7add/YT54RXs9Q4rKInebxdHdG6JFs5BaTg45gyjDu0rvNVcGeHODg==", + "dev": true + }, + "node_modules/@docusaurus/types": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/types/-/types-3.2.1.tgz", + "integrity": "sha512-n/toxBzL2oxTtRTOFiGKsHypzn/Pm+sXyw+VSk1UbqbXQiHOwHwts55bpKwbcUgA530Is6kix3ELiFOv9GAMfw==", + "dependencies": { + "@mdx-js/mdx": "^3.0.0", + "@types/history": "^4.7.11", + "@types/react": "*", + "commander": "^5.1.0", + "joi": "^17.9.2", + "react-helmet-async": "^1.3.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1", + "webpack-merge": "^5.9.0" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@docusaurus/utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils/-/utils-3.2.1.tgz", + "integrity": "sha512-DPkIS/EPc+pGAV798PUXgNzJFM3HJouoQXgr0KDZuJVz1EkWbDLOcQwLIz8Qx7liI9ddfkN/TXTRQdsTPZNakw==", + "dependencies": { + "@docusaurus/logger": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "@svgr/webpack": "^6.5.1", + "escape-string-regexp": "^4.0.0", + "file-loader": "^6.2.0", + "fs-extra": "^11.1.1", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", + "gray-matter": "^4.0.3", + "jiti": "^1.20.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "micromatch": "^4.0.5", + "prompts": "^2.4.2", + "resolve-pathname": "^3.0.0", + "shelljs": "^0.8.5", + "tslib": "^2.6.0", + "url-loader": "^4.1.1", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/types": "*" + }, + "peerDependenciesMeta": { + "@docusaurus/types": { + "optional": true + } + } + }, + "node_modules/@docusaurus/utils-common": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-common/-/utils-common-3.2.1.tgz", + "integrity": "sha512-N5vadULnRLiqX2QfTjVEU3u5vo6RG2EZTdyXvJdzDOdrLCGIZAfnf/VkssinFZ922sVfaFfQ4FnStdhn5TWdVg==", + "dependencies": { + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "@docusaurus/types": "*" + }, + "peerDependenciesMeta": { + "@docusaurus/types": { + "optional": true + } + } + }, + "node_modules/@docusaurus/utils-validation": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@docusaurus/utils-validation/-/utils-validation-3.2.1.tgz", + "integrity": "sha512-+x7IR9hNMXi62L1YAglwd0s95fR7+EtirjTxSN4kahYRWGqOi3jlQl1EV0az/yTEvKbxVvOPcdYicGu9dk4LJw==", + "dependencies": { + "@docusaurus/logger": "3.2.1", + "@docusaurus/utils": "3.2.1", + "@docusaurus/utils-common": "3.2.1", + "joi": "^17.9.2", + "js-yaml": "^4.1.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=18.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" + }, + "node_modules/@mdx-js/mdx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.0.1.tgz", + "integrity": "sha512-eIQ4QTrOWyL3LWEe/bu6Taqzq2HQvHcyTMaOrI95P2/LmJE7AsfPfgJGuFLPVqBUE1BC1rik3VIhU+s9u72arA==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-to-js": "^2.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-estree": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "periscopic": "^3.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz", + "integrity": "sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "node_modules/@pnpm/npm-conf": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz", + "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@slorber/remark-comment": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@slorber/remark-comment/-/remark-comment-1.0.0.tgz", + "integrity": "sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.1.0", + "micromark-util-symbol": "^1.0.1" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.5.1.tgz", + "integrity": "sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-6.5.1.tgz", + "integrity": "sha512-8DPaVVE3fd5JKuIC29dqyMB54sA6mfgki2H2+swh+zNJoynC8pMPzOkidqHOSc6Wj032fhl8Z0TVn1GiPpAiJg==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-6.5.1.tgz", + "integrity": "sha512-FwOEi0Il72iAzlkaHrlemVurgSQRDFbk0OC8dSvD5fSBPHltNh7JtLsxmZUhjYBZo2PpcU/RJvvi6Q0l7O7ogw==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-6.5.1.tgz", + "integrity": "sha512-gWGsiwjb4tw+ITOJ86ndY/DZZ6cuXMNE/SjcDRg+HLuCmwpcjOktwRF9WgAiycTqJD/QXqL2f8IzE2Rzh7aVXA==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-6.5.1.tgz", + "integrity": "sha512-2jT3nTayyYP7kI6aGutkyfJ7UMGtuguD72OjeGLwVNyfPRBD8zQthlvL+fAbAKk5n9ZNcvFkp/b1lZ7VsYqVJg==", + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-6.5.1.tgz", + "integrity": "sha512-a1p6LF5Jt33O3rZoVRBqdxL350oge54iZWHNI6LJB5tQ7EelvD/Mb1mfBiZNAan0dt4i3VArkFRjA4iObuNykQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-6.5.1.tgz", + "integrity": "sha512-6127fvO/FF2oi5EzSQOAjo1LE3OtNVh11R+/8FXa+mHx1ptAaS4cknIjnUA7e6j6fwGGJ17NzaTJFUwOV2zwCw==", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^6.5.1", + "@svgr/babel-plugin-remove-jsx-attribute": "*", + "@svgr/babel-plugin-remove-jsx-empty-expression": "*", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^6.5.1", + "@svgr/babel-plugin-svg-dynamic-title": "^6.5.1", + "@svgr/babel-plugin-svg-em-dimensions": "^6.5.1", + "@svgr/babel-plugin-transform-react-native-svg": "^6.5.1", + "@svgr/babel-plugin-transform-svg-component": "^6.5.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-6.5.1.tgz", + "integrity": "sha512-/xdLSWxK5QkqG524ONSjvg3V/FkNyCv538OIBdQqPNaAta3AsXj/Bd2FbvR87yMbXO2hFSWiAe/Q6IkVPDw+mw==", + "dependencies": { + "@babel/core": "^7.19.6", + "@svgr/babel-preset": "^6.5.1", + "@svgr/plugin-jsx": "^6.5.1", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-6.5.1.tgz", + "integrity": "sha512-1hnUxxjd83EAxbL4a0JDJoD3Dao3hmjvyvyEV8PzWmLK3B9m9NPlW7GKjFyoWE8nM7HnXzPcmmSyOW8yOddSXw==", + "dependencies": { + "@babel/types": "^7.20.0", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-6.5.1.tgz", + "integrity": "sha512-+UdQxI3jgtSjCykNSlEMuy1jSRQlGC7pqBCPvkG/2dATdWo082zHTTK3uhnAju2/6XpE6B5mZ3z4Z8Ns01S8Gw==", + "dependencies": { + "@babel/core": "^7.19.6", + "@svgr/babel-preset": "^6.5.1", + "@svgr/hast-util-to-babel-ast": "^6.5.1", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "^6.0.0" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-6.5.1.tgz", + "integrity": "sha512-omvZKf8ixP9z6GWgwbtmP9qQMPX4ODXi+wzbVZgomNFsUIlHA1sf4fThdwTWSsZGgvGAG6yE+b/F5gWUkcZ/iQ==", + "dependencies": { + "cosmiconfig": "^7.0.1", + "deepmerge": "^4.2.2", + "svgo": "^2.8.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/webpack": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-6.5.1.tgz", + "integrity": "sha512-cQ/AsnBkXPkEK8cLbv4Dm7JGXq2XrumKnL1dRpJD9rIO2fTIlJI9a1uCciYG1F2aUsox/hJQyNGbt3soDxSRkA==", + "dependencies": { + "@babel/core": "^7.19.6", + "@babel/plugin-transform-react-constant-elements": "^7.18.12", + "@babel/preset-env": "^7.19.4", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.18.6", + "@svgr/core": "^6.5.1", + "@svgr/plugin-jsx": "^6.5.1", + "@svgr/plugin-svgo": "^6.5.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/acorn": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", + "integrity": "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.56.10", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", + "integrity": "sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz", + "integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/gtag.js": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@types/gtag.js/-/gtag.js-0.0.12.tgz", + "integrity": "sha512-YQV9bUsemkzG81Ea295/nF/5GijnD2Af7QhEofh7xu+kvCN6RdodgNwwGWXB5GMI3NoyvQo0odNctoH/qLMIpg==" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==" + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==" + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.14", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", + "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + "node_modules/@types/mdast": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", + "integrity": "sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" + }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, + "node_modules/@types/node": { + "version": "20.12.7", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", + "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.11.tgz", + "integrity": "sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, + "node_modules/@types/prismjs": { + "version": "1.26.3", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.3.tgz", + "integrity": "sha512-A0D0aTXvjlqJ5ZILMz3rNfDBOx9hHxLZYv2by47Sm/pqW35zzjusrZTryatjN/Rf8Us2gZrJD+KeHbUSTux1Cw==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + }, + "node_modules/@types/qs": { + "version": "6.9.15", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz", + "integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" + }, + "node_modules/@types/react": { + "version": "18.2.79", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.79.tgz", + "integrity": "sha512-RwGAGXPl9kSXwdNTafkOEuFrTBD5SA2B3iEB96xi8+xu5ddUa/cpvyVCSNn+asgLCTHkb5ZxN8gbuibYJi4s1w==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-config": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.11.tgz", + "integrity": "sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "^5.1.0" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==" + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", + "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" + }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.12.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "dependencies": { + "@webassemblyjs/ast": "1.12.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/address/-/address-1.2.2.tgz", + "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/algoliasearch": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.23.3.tgz", + "integrity": "sha512-Le/3YgNvjW9zxIQMRhUHuhiUjAlKY/zsdZpfq4dlLqg6mEm0nL6yk+7f2hDOtLpxsgE4jSzDmvHL7nXdBp5feg==", + "dependencies": { + "@algolia/cache-browser-local-storage": "4.23.3", + "@algolia/cache-common": "4.23.3", + "@algolia/cache-in-memory": "4.23.3", + "@algolia/client-account": "4.23.3", + "@algolia/client-analytics": "4.23.3", + "@algolia/client-common": "4.23.3", + "@algolia/client-personalization": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/logger-console": "4.23.3", + "@algolia/recommend": "4.23.3", + "@algolia/requester-browser-xhr": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/requester-node-http": "4.23.3", + "@algolia/transporter": "4.23.3" + } + }, + "node_modules/algoliasearch-helper": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.18.0.tgz", + "integrity": "sha512-ZXvA8r6VG46V343jnIE7Tei8Xr0/9N8YhD27joC0BKxeogQyvNu7O37i510wA7FnrDjoa/tFhK90WUaBlkaqnw==", + "dependencies": { + "@algolia/events": "^4.0.1" + }, + "peerDependencies": { + "algoliasearch": ">= 3.1 < 6" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/astring": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz", + "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-loader": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.1.3.tgz", + "integrity": "sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw==", + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", + "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.2", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz", + "integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.1", + "core-js-compat": "^3.36.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", + "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==" + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", + "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/bonjour-service": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.2.1.tgz", + "integrity": "sha512-oSzCS2zV14bh2kji6vNe7vrpJYCHGvcZnlffFQ1MEoX/WOeQ/teD8SYWKR942OI3INjq8OMNJlbPK5LLLUxFDw==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" + }, + "node_modules/boxen": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-6.2.1.tgz", + "integrity": "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^6.2.0", + "chalk": "^4.1.2", + "cli-boxes": "^3.0.0", + "string-width": "^5.0.1", + "type-fest": "^2.5.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", + "node-releases": "^2.0.14", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request/node_modules/normalize-url": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz", + "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001612", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz", + "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.4.tgz", + "integrity": "sha512-Lm3L0p+/npIQWNIiyF/nAn7T5dnOwR3xNTHXYEBFBFVPXzCVNZ5lqEC/1eo/EVfpDsQ1I+TX4ORPQgp+UI0CRw==", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + }, + "node_modules/combine-promises": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/combine-promises/-/combine-promises-1.2.0.tgz", + "integrity": "sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compressible/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/configstore": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", + "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", + "dependencies": { + "dot-prop": "^6.0.1", + "graceful-fs": "^4.2.6", + "unique-string": "^3.0.0", + "write-file-atomic": "^3.0.3", + "xdg-basedir": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/yeoman/configstore?sponsor=1" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "node_modules/copy-text-to-clipboard": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz", + "integrity": "sha512-RnJFp1XR/LOBDckxTib5Qjr/PMfkatD0MUCQgdpqS8MdKiNUzBjAQBEN6oUy+jW7LI93BBG3DtMB2KOOKpGs2Q==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-js": { + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.0.tgz", + "integrity": "sha512-fu5vHevQ8ZG4og+LXug8ulUtVxjOcEYvifJr7L5Bfq9GOztVqsKd9/59hUk2ZSbCrS3BqUr3EpaYGIYzq7g3Ug==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.37.0.tgz", + "integrity": "sha512-vYq4L+T8aS5UuFg4UwDhc7YNRWVeVZwltad9C/jV3R2LgVOpS9BDr7l/WL6BN0dbV3k1XejPTHqqEzJgsa0frA==", + "dependencies": { + "browserslist": "^4.23.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.37.0.tgz", + "integrity": "sha512-d3BrpyFr5eD4KcbRvQ3FTUx/KWmaDesr7+a3+1+P46IUnNoEt+oiLijPINZMEon7w9oGkIINWxrBAU9DEciwFQ==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/css-declaration-sorter": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", + "integrity": "sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==", + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-loader": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", + "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-4.2.2.tgz", + "integrity": "sha512-s3Of/4jKfw1Hj9CxEO1E5oXhQAxlayuHO2y/ML+C6I9sQ7FdzfEV6QgMLN3vI+qFsjJGIAFLKtQK7t8BOXAIyA==", + "dependencies": { + "cssnano": "^5.1.8", + "jest-worker": "^29.1.2", + "postcss": "^8.4.17", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "lightningcss": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "5.1.15", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-5.1.15.tgz", + "integrity": "sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==", + "dependencies": { + "cssnano-preset-default": "^5.2.14", + "lilconfig": "^2.0.3", + "yaml": "^1.10.2" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-advanced": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.10.tgz", + "integrity": "sha512-fnYJyCS9jgMU+cmHO1rPSPf9axbQyD7iUhLO5Df6O4G+fKIOMps+ZbU0PdGFejFBBZ3Pftf18fn1eG7MAPUSWQ==", + "dependencies": { + "autoprefixer": "^10.4.12", + "cssnano-preset-default": "^5.2.14", + "postcss-discard-unused": "^5.1.0", + "postcss-merge-idents": "^5.1.1", + "postcss-reduce-idents": "^5.2.0", + "postcss-zindex": "^5.1.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-preset-default": { + "version": "5.2.14", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-5.2.14.tgz", + "integrity": "sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==", + "dependencies": { + "css-declaration-sorter": "^6.3.1", + "cssnano-utils": "^3.1.0", + "postcss-calc": "^8.2.3", + "postcss-colormin": "^5.3.1", + "postcss-convert-values": "^5.1.3", + "postcss-discard-comments": "^5.1.2", + "postcss-discard-duplicates": "^5.1.0", + "postcss-discard-empty": "^5.1.1", + "postcss-discard-overridden": "^5.1.0", + "postcss-merge-longhand": "^5.1.7", + "postcss-merge-rules": "^5.1.4", + "postcss-minify-font-values": "^5.1.0", + "postcss-minify-gradients": "^5.1.1", + "postcss-minify-params": "^5.1.4", + "postcss-minify-selectors": "^5.2.1", + "postcss-normalize-charset": "^5.1.0", + "postcss-normalize-display-values": "^5.1.0", + "postcss-normalize-positions": "^5.1.1", + "postcss-normalize-repeat-style": "^5.1.1", + "postcss-normalize-string": "^5.1.0", + "postcss-normalize-timing-functions": "^5.1.0", + "postcss-normalize-unicode": "^5.1.1", + "postcss-normalize-url": "^5.1.0", + "postcss-normalize-whitespace": "^5.1.1", + "postcss-ordered-values": "^5.1.3", + "postcss-reduce-initial": "^5.1.2", + "postcss-reduce-transforms": "^5.1.0", + "postcss-svgo": "^5.1.0", + "postcss-unique-selectors": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/cssnano-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-3.1.0.tgz", + "integrity": "sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==" + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/del": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz", + "integrity": "sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==", + "dependencies": { + "globby": "^11.0.1", + "graceful-fs": "^4.2.4", + "is-glob": "^4.0.1", + "is-path-cwd": "^2.2.0", + "is-path-inside": "^3.0.2", + "p-map": "^4.0.0", + "rimraf": "^3.0.2", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, + "node_modules/detect-port": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.5.1.tgz", + "integrity": "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==", + "dependencies": { + "address": "^1.0.1", + "debug": "4" + }, + "bin": { + "detect": "bin/detect-port.js", + "detect-port": "bin/detect-port.js" + } + }, + "node_modules/detect-port-alt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/detect-port-alt/-/detect-port-alt-1.1.6.tgz", + "integrity": "sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q==", + "dependencies": { + "address": "^1.0.1", + "debug": "^2.6.0" + }, + "bin": { + "detect": "bin/detect-port", + "detect-port": "bin/detect-port" + }, + "engines": { + "node": ">= 4.2.1" + } + }, + "node_modules/detect-port-alt/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/detect-port-alt/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/electron-to-chromium": { + "version": "1.4.747", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.747.tgz", + "integrity": "sha512-+FnSWZIAvFHbsNVmUxhEqWiaOiPMcfum1GQzlWCg/wLigVtshOsjXHyEFfmt6cFK6+HkS3QOJBv6/3OPumbBfw==" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/emoticon": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.0.1.tgz", + "integrity": "sha512-dqx7eA9YaqyvYtUhJwT4rC1HIp82j5ybS1/vQ42ur+jBe17dJMwZE4+gvL1XadSFfxaPFFGt3Xsw+Y8akThDlw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.16.0.tgz", + "integrity": "sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", + "integrity": "sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==" + }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-value-to-estree": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/estree-util-value-to-estree/-/estree-util-value-to-estree-3.1.1.tgz", + "integrity": "sha512-5mvUrF2suuv5f5cGDnDphIy4/gW86z82kl5qG6mM9z04SEQI4FB5Apmaw/TGEf3l55nLtMs5s51dmhUzvAHQCA==", + "dependencies": { + "@types/estree": "^1.0.0", + "is-plain-obj": "^4.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-2.2.0.tgz", + "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eval": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/eval/-/eval-0.1.8.tgz", + "integrity": "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==", + "dependencies": { + "@types/node": "*", + "require-like": ">= 0.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.2", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "node_modules/express/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/fast-url-parser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-url-parser/-/fast-url-parser-1.1.3.tgz", + "integrity": "sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==", + "dependencies": { + "punycode": "^1.3.2" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/feed": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", + "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "dependencies": { + "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/file-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/file-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/filesize": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz", + "integrity": "sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz", + "integrity": "sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==", + "dependencies": { + "@babel/code-frame": "^7.8.3", + "@types/json-schema": "^7.0.5", + "chalk": "^4.1.0", + "chokidar": "^3.4.2", + "cosmiconfig": "^6.0.0", + "deepmerge": "^4.2.2", + "fs-extra": "^9.0.0", + "glob": "^7.1.6", + "memfs": "^3.1.2", + "minimatch": "^3.0.4", + "schema-utils": "2.7.0", + "semver": "^7.3.2", + "tapable": "^1.0.0" + }, + "engines": { + "node": ">=10", + "yarn": ">=1.0.0" + }, + "peerDependencies": { + "eslint": ">= 6", + "typescript": ">= 2.7", + "vue-template-compiler": "*", + "webpack": ">= 4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + }, + "vue-template-compiler": { + "optional": true + } + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz", + "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.7.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", + "integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==", + "dependencies": { + "@types/json-schema": "^7.0.4", + "ajv": "^6.12.2", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/tapable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-1.1.3.tgz", + "integrity": "sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", + "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-1.5.0.tgz", + "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-dirs/node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "12.6.1", + "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", + "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/@sindresorhus/is": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-yarn": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz", + "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.1.tgz", + "integrity": "sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^8.0.0", + "property-information": "^6.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.0.2.tgz", + "integrity": "sha512-PldBy71wO9Uq1kyaMch9AHIghtQvIwxBUkv823pKmkTM3oV1JxtsTNYdevMxvUHqcnOAuO65JKU2+0NOxc2ksA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.0.tgz", + "integrity": "sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^0.4.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz", + "integrity": "sha512-H/y0+IWPdsLLS738P8tDnrQ8Z+dj12zQQ6WC11TIM21C8WFVoIxcqWXf2H3hiTVZjF1AWqoimGwrTWecWrnmRQ==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-object": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime/node_modules/inline-style-parser": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", + "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" + }, + "node_modules/hast-util-to-jsx-runtime/node_modules/style-to-object": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.6.tgz", + "integrity": "sha512-khxq+Qm3xEyZfKd/y9L3oIWQimxuc4STrQKtQn8aSDRHb8mFgpukgX1hdzfrMEW6JCjyJ8p89x+IUMVnCBI1PA==", + "dependencies": { + "inline-style-parser": "0.2.3" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-8.0.0.tgz", + "integrity": "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, + "node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-entities": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==" + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "engines": { + "node": ">=14" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz", + "integrity": "sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw==", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/html-webpack-plugin/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, + "node_modules/html-webpack-plugin/node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", + "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/infima": { + "version": "0.2.0-alpha.43", + "resolved": "https://registry.npmjs.org/infima/-/infima-0.2.0-alpha.43.tgz", + "integrity": "sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" + }, + "node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==" + }, + "node_modules/interpret": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", + "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", + "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-2.2.0.tgz", + "integrity": "sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", + "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-root": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-root/-/is-root-2.1.0.tgz", + "integrity": "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-yarn-global": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz", + "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", + "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/joi": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.0.tgz", + "integrity": "sha512-9qcrTyoBmFZRNHeVP4edKqIUEgFzq7MHvTNSDuHSqkpOPtiBkgNgcmTSqmiw1kw9tdKaiddvIDv/eCJDxmqWCA==", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "engines": { + "node": ">=6" + } + }, + "node_modules/latest-version": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", + "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", + "dependencies": { + "package-json": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/launch-editor": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", + "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", + "integrity": "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.0.0.tgz", + "integrity": "sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz", + "integrity": "sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", + "integrity": "sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", + "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.0.tgz", + "integrity": "sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", + "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.0.tgz", + "integrity": "sha512-fGCu8eWdKUKNu5mohVGkhBXCXGnOTLuFqOvGMvdikr+J1w7lDJgxThOKpwRWzzbyXAU2hhSwsmssOY4yTokluw==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.1.2.tgz", + "integrity": "sha512-eKMQDeywY2wlHc97k5eD8VC+9ASMjN8ItEZQNGwJ6E0XWKiW/Z0V5/H8pvoXUf+y+Mj0VIgeRRbujBmFn4FTyA==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-remove-position": "^5.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.1.0.tgz", + "integrity": "sha512-/e2l/6+OdGp/FB+ctrJ9Avz71AN/GRH3oi/3KAx/kMnoUsD6q0woXlDT8lLEeViVKE7oZxE7RXzvO3T8kF2/sA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", + "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", + "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.1.tgz", + "integrity": "sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-3.0.0.tgz", + "integrity": "sha512-61OI07qpQrERc+0wEysLHMvoiO3s2R56x5u7glHq2Yqq6EHbH4dW25G9GfDdGCDYqA21KE6DWgNSzxSwHc2hSg==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.0.0.tgz", + "integrity": "sha512-rTHfnpt/Q7dEAK1Y5ii0W8bhfJlVJFnJMHIPisfPK3gpVNuOP0VnRl96+YJ3RYWV/P4gFeQoGKNlT3RhuvpqAg==", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.0.0.tgz", + "integrity": "sha512-6Rzu0CYRKDv3BfLAUnZsSlzx3ak6HAoI85KTiijuKIz5UxZxbUI+pD6oHgw+6UtQuiRwnGRhzMmPRv4smcz0fg==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-c3BR1ClMp5fxxmwP6AoOY2fXO9U8uFMKs4ADD66ahLTNcwzSCyRVU4k7LPV5Nxo/VJiR4TdzxRQY2v3qIUceCw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.0.0.tgz", + "integrity": "sha512-PoHlhypg1ItIucOaHmKE8fbin3vTLpDOUg8KAr8gRCF1MOZI9Nquq2i/44wFvviM4WuxJzc3demT8Y3dkfvYrw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.0.1.tgz", + "integrity": "sha512-cY5PzGcnULaN5O7T+cOzfMoHjBW7j+T9D2sucA5d/KbsBTPcYdebm9zUd9zzdgJGCwahV+/W78Z3nbulBYVbTw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.0.tgz", + "integrity": "sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.0.tgz", + "integrity": "sha512-uvhhss8OGuzR4/N17L1JwvmJIpPhAd8oByMawEKx6NVdBCbesjH4t+vjEp3ZXft9DwvlKSD07fCeI44/N0Vf2w==", + "dependencies": { + "@types/acorn": "^4.0.0", + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", + "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-factory-label": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", + "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.1.tgz", + "integrity": "sha512-F0ccWIUHRLRrYp5TC9ZYXmZo+p2AM13ggbsW4T0b5CRKP8KHVRB8t4pwtBgTxtjRmwrK0Irwm7vs2JOZabHZfg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-space/node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-factory-title": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", + "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", + "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-character/node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", + "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", + "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", + "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", + "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", + "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", + "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.2.tgz", + "integrity": "sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/acorn": "^4.0.0", + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-events-to-acorn/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", + "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", + "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-normalize-identifier/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", + "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", + "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.1.tgz", + "integrity": "sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", + "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark/node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark/node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark/node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz", + "integrity": "sha512-Zs1YsZVfemekSZG+44vBsYTLQORkPMwnlv+aehcxK/NLKC+EGhDB39/YePYYqx/sTk6NnYpuqikhSn7+JIevTA==", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-emoji": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.1.3.tgz", + "integrity": "sha512-E2WEOVsgs7O16zsURJ/eH8BqhF029wGpEOnv7Urwdo2wmQanOACwJQh0devF9D9RhoZru0+9JXIS0dBXIAz+lA==", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", + "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", + "dependencies": { + "got": "^12.1.0", + "registry-auth-token": "^5.0.1", + "registry-url": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", + "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-numeric-range": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz", + "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==" + }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", + "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "dependencies": { + "domhandler": "^5.0.2", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dependencies": { + "find-up": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-up/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-up/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/pkg-up/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz", + "integrity": "sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==", + "dependencies": { + "postcss-selector-parser": "^6.0.9", + "postcss-value-parser": "^4.2.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-colormin": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-5.3.1.tgz", + "integrity": "sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "colord": "^2.9.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-convert-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz", + "integrity": "sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-comments": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz", + "integrity": "sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz", + "integrity": "sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-empty": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz", + "integrity": "sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz", + "integrity": "sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-discard-unused": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-5.1.0.tgz", + "integrity": "sha512-KwLWymI9hbwXmJa0dkrzpRbSJEh0vVUd7r8t0yOGPcfKzyJJxFM8kLyC5Ev9avji6nY95pOp1W6HqIrfT+0VGw==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-loader": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", + "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", + "dependencies": { + "cosmiconfig": "^8.3.5", + "jiti": "^1.20.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-loader/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss-merge-idents": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-5.1.1.tgz", + "integrity": "sha512-pCijL1TREiCoog5nQp7wUe+TUonA2tC2sQ54UGeMmryK3UFGIYKqDyjnqd6RcuI4znFn9hWSLNN8xKE/vWcUQw==", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz", + "integrity": "sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^5.1.1" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-merge-rules": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-5.1.4.tgz", + "integrity": "sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^3.1.0", + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz", + "integrity": "sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz", + "integrity": "sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==", + "dependencies": { + "colord": "^2.9.1", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-params": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz", + "integrity": "sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==", + "dependencies": { + "browserslist": "^4.21.4", + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz", + "integrity": "sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", + "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", + "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz", + "integrity": "sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz", + "integrity": "sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz", + "integrity": "sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz", + "integrity": "sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-string": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz", + "integrity": "sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz", + "integrity": "sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz", + "integrity": "sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz", + "integrity": "sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==", + "dependencies": { + "normalize-url": "^6.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz", + "integrity": "sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-ordered-values": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz", + "integrity": "sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==", + "dependencies": { + "cssnano-utils": "^3.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-idents": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-5.2.0.tgz", + "integrity": "sha512-BTrLjICoSB6gxbc58D5mdBK8OhXRDqud/zodYfdSi52qvDHdMwk+9kB9xsM8yJThH/sZU5A6QVSmMmaN001gIg==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-5.1.2.tgz", + "integrity": "sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==", + "dependencies": { + "browserslist": "^4.21.4", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz", + "integrity": "sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.16", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", + "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-sort-media-queries": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/postcss-sort-media-queries/-/postcss-sort-media-queries-4.4.1.tgz", + "integrity": "sha512-QDESFzDDGKgpiIh4GYXsSy6sek2yAwQx1JASl5AxBtU1Lq2JfKBljIPNdil989NcSKRQX1ToiaKphImtBuhXWw==", + "dependencies": { + "sort-css-media-queries": "2.1.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.4.16" + } + }, + "node_modules/postcss-svgo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-5.1.0.tgz", + "integrity": "sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^2.7.0" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz", + "integrity": "sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==", + "dependencies": { + "postcss-selector-parser": "^6.0.5" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/postcss-zindex": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-5.1.0.tgz", + "integrity": "sha512-fgFMf0OtVSBR1va1JNHYgMxYk73yhn/qb4uQDq1DLGYolz8gHCyr/sesEuGUaYs58E3ZJRcpoGuPVoB7Meiq9A==", + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pretty-time/-/pretty-time-1.1.0.tgz", + "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/prism-react-renderer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.3.1.tgz", + "integrity": "sha512-Rdf+HzBLR7KYjzpJ1rSoxT9ioO85nZngQEoFIhL07XhtJHlCU3SOz0GJ6+qvMyQe0Se+BV3qpe6Yd/NmQF5Juw==", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" + }, + "node_modules/pupa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", + "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dev-utils": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", + "integrity": "sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==", + "dependencies": { + "@babel/code-frame": "^7.16.0", + "address": "^1.1.2", + "browserslist": "^4.18.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "detect-port-alt": "^1.1.6", + "escape-string-regexp": "^4.0.0", + "filesize": "^8.0.6", + "find-up": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^6.5.0", + "global-modules": "^2.0.0", + "globby": "^11.0.4", + "gzip-size": "^6.0.0", + "immer": "^9.0.7", + "is-root": "^2.1.0", + "loader-utils": "^3.2.0", + "open": "^8.4.0", + "pkg-up": "^3.1.0", + "prompts": "^2.4.2", + "react-error-overlay": "^6.0.11", + "recursive-readdir": "^2.2.2", + "shell-quote": "^1.7.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/react-dev-utils/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/react-dev-utils/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dev-utils/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-dev-utils/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-error-overlay": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", + "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, + "node_modules/react-helmet-async": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-json-view-lite": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-json-view-lite/-/react-json-view-lite-1.3.0.tgz", + "integrity": "sha512-aN1biKC5v4DQkmQBlZjuMFR09MKZGMPtIg+cut8zEeg2HXd6gl2gRy0n4HMacHf0dznQgo0SVXN7eT8zV3hEuQ==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-loadable": { + "name": "@docusaurus/react-loadable", + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz", + "integrity": "sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ==", + "dependencies": { + "@types/react": "*", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-loadable-ssr-addon-v5-slorber": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz", + "integrity": "sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A==", + "dependencies": { + "@babel/runtime": "^7.10.3" + }, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "react-loadable": "*", + "webpack": ">=4.41.1 || 5.x" + } + }, + "node_modules/react-router": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-config": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/react-router-config/-/react-router-config-5.1.1.tgz", + "integrity": "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==", + "dependencies": { + "@babel/runtime": "^7.1.2" + }, + "peerDependencies": { + "react": ">=15", + "react-router": ">=5" + } + }, + "node_modules/react-router-dom": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reading-time": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", + "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==" + }, + "node_modules/rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==", + "dependencies": { + "resolve": "^1.1.6" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/recursive-readdir": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz", + "integrity": "sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==", + "dependencies": { + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", + "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dependencies": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/registry-auth-token": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", + "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==", + "dependencies": { + "@pnpm/npm-conf": "^2.1.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/remark-directive": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-3.0.0.tgz", + "integrity": "sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-emoji": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-4.0.1.tgz", + "integrity": "sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg==", + "dependencies": { + "@types/mdast": "^4.0.2", + "emoticon": "^4.0.1", + "mdast-util-find-and-replace": "^3.0.1", + "node-emoji": "^2.1.0", + "unified": "^11.0.4" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/remark-frontmatter": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz", + "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-frontmatter": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", + "integrity": "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.0.1.tgz", + "integrity": "sha512-3Pz3yPQ5Rht2pM5R+0J2MrGoBSrzf+tJG94N+t/ilfdh8YLyyKYtidAYwTveB20BoHAcwIopOUqhcmh2F7hGYA==", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.0.tgz", + "integrity": "sha512-z3tJrAs2kIs1AqIIy6pzHmAHlF1hWQ+OdY4/hv+Wxe35EhyLKcajL33iUEn3ScxtFox9nUvRufR/Zre8Q08H/g==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/renderkid/node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/renderkid/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-like": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/require-like/-/require-like-0.1.2.tgz", + "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", + "engines": { + "node": "*" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" + }, + "node_modules/responselike": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rtl-detect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz", + "integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==" + }, + "node_modules/rtlcss": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.1.1.tgz", + "integrity": "sha512-/oVHgBtnPNcggP2aVXQjSy6N1mMAfHg4GSag0QtZBlD5bdDgAHwr4pydqJGd+SUCu9260+Pjqbjwtvu7EMH1KQ==", + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0", + "postcss": "^8.4.21", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "rtlcss": "bin/rtlcss.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sax": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", + "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/search-insights": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.13.0.tgz", + "integrity": "sha512-Orrsjf9trHHxFRuo9/rzm0KIWmgzE8RMlZMzuhZOJ01Rnz3D0YBAe+V6473t6/H6c7irs6Lt48brULAiRWb3Vw==", + "peer": true + }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==" + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", + "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/semver/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/send/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-handler": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.5.tgz", + "integrity": "sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "fast-url-parser": "1.1.3", + "mime-types": "2.1.18", + "minimatch": "3.1.2", + "path-is-inside": "1.0.2", + "path-to-regexp": "2.2.1", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/path-to-regexp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.2.1.tgz", + "integrity": "sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==" + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==" + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shelljs": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.8.5.tgz", + "integrity": "sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==", + "dependencies": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + }, + "bin": { + "shjs": "bin/shjs" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, + "node_modules/sitemap": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-7.1.1.tgz", + "integrity": "sha512-mK3aFtjz4VdJN0igpIJrinf3EO8U8mxOPsTBzSsy06UtjZQJ3YY3o3Xa7zSc5nMqcMrRwlChHZ18Kxg0caiPBg==", + "dependencies": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.2.4" + }, + "bin": { + "sitemap": "dist/cli.js" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=5.6.0" + } + }, + "node_modules/sitemap/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==" + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/sort-css-media-queries": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sort-css-media-queries/-/sort-css-media-queries-2.1.0.tgz", + "integrity": "sha512-IeWvo8NkNiY2vVYdPa27MCQiR0MN0M80johAYFVxWWXQ44KU84WNxjslwBHmc/7ZL2ccwkM7/e6S5aiKZXm7jA==", + "engines": { + "node": ">= 6.3.0" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, + "node_modules/srcset": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/srcset/-/srcset-4.0.0.tgz", + "integrity": "sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-object": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", + "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/stylehacks": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", + "integrity": "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==", + "dependencies": { + "browserslist": "^4.21.4", + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >=14.0" + }, + "peerDependencies": { + "postcss": "^8.2.15" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==" + }, + "node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/svgo/node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/svgo/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/svgo/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/svgo/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.30.4", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.30.4.tgz", + "integrity": "sha512-xRdd0v64a8mFK9bnsKVdoNP9GQIKUAaJPTaqEQDL4w/J8WaW4sWXXoMZ+6SimPkfT5bElreXf8m9HnmPc3E1BQ==", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==" + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unified": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.4.tgz", + "integrity": "sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-notifier": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz", + "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", + "dependencies": { + "boxen": "^7.0.0", + "chalk": "^5.0.1", + "configstore": "^6.0.0", + "has-yarn": "^3.0.0", + "import-lazy": "^4.0.0", + "is-ci": "^3.0.1", + "is-installed-globally": "^0.4.0", + "is-npm": "^6.0.0", + "is-yarn-global": "^0.4.0", + "latest-version": "^7.0.0", + "pupa": "^3.1.0", + "semver": "^7.3.7", + "semver-diff": "^4.0.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/boxen": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/url-loader": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz", + "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", + "dependencies": { + "loader-utils": "^2.0.0", + "mime-types": "^2.1.27", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "file-loader": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "file-loader": { + "optional": true + } + } + }, + "node_modules/url-loader/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/url-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/url-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/url-loader/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/value-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", + "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.2.tgz", + "integrity": "sha512-NXPYyxyBSH7zB5U6+3uDdd6Nybz6o6/od9rk8bp9H8GR3L+cm/fC0uUTbqBmUTnMCUDslAGBOIKNfvvb+gGlDg==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/watchpack": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", + "integrity": "sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webpack": { + "version": "5.91.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.91.0.tgz", + "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.16.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.15.2", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz", + "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.4", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", + "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpackbar": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/webpackbar/-/webpackbar-5.0.2.tgz", + "integrity": "sha512-BmFJo7veBDgQzfWXl/wwYXr/VFus0614qZ8i9znqcl9fnEdiVkdbi0TedLQ6xAK92HZHDJ0QmyQ0fmuZPAgCYQ==", + "dependencies": { + "chalk": "^4.1.0", + "consola": "^2.15.3", + "pretty-time": "^1.1.0", + "std-env": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "webpack": "3 || 4 || 5" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.9", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/documentation/documentation/package.json b/documentation/documentation/package.json new file mode 100644 index 00000000..020ddfb6 --- /dev/null +++ b/documentation/documentation/package.json @@ -0,0 +1,47 @@ +{ + "name": "documentation", + "version": "0.0.0", + "private": true, + "scripts": { + "docusaurus": "docusaurus", + "start": "docusaurus start", + "build": "docusaurus build", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", + "serve": "docusaurus serve", + "write-translations": "docusaurus write-translations", + "write-heading-ids": "docusaurus write-heading-ids", + "typecheck": "tsc" + }, + "dependencies": { + "@docusaurus/core": "3.2.1", + "@docusaurus/preset-classic": "3.2.1", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "prism-react-renderer": "^2.3.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "3.2.1", + "@docusaurus/tsconfig": "3.2.1", + "@docusaurus/types": "3.2.1", + "typescript": "~5.2.2" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 3 chrome version", + "last 3 firefox version", + "last 5 safari version" + ] + }, + "engines": { + "node": ">=18.0" + } +} diff --git a/documentation/documentation/sidebars.ts b/documentation/documentation/sidebars.ts new file mode 100644 index 00000000..acc7685a --- /dev/null +++ b/documentation/documentation/sidebars.ts @@ -0,0 +1,31 @@ +import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; + +/** + * Creating a sidebar enables you to: + - create an ordered group of docs + - render a sidebar for each doc of that group + - provide next/previous navigation + + The sidebars can be generated from the filesystem, or explicitly defined here. + + Create as many sidebars as you want. + */ +const sidebars: SidebarsConfig = { + // By default, Docusaurus generates a sidebar from the docs folder structure + tutorialSidebar: [{type: 'autogenerated', dirName: '.'}], + + // But you can create a sidebar manually + /* + tutorialSidebar: [ + 'intro', + 'hello', + { + type: 'category', + label: 'Tutorial', + items: ['tutorial-basics/create-a-document'], + }, + ], + */ +}; + +export default sidebars; diff --git a/documentation/documentation/src/components/HomepageFeatures/index.tsx b/documentation/documentation/src/components/HomepageFeatures/index.tsx new file mode 100644 index 00000000..50a9e6f4 --- /dev/null +++ b/documentation/documentation/src/components/HomepageFeatures/index.tsx @@ -0,0 +1,70 @@ +import clsx from 'clsx'; +import Heading from '@theme/Heading'; +import styles from './styles.module.css'; + +type FeatureItem = { + title: string; + Svg: React.ComponentType>; + description: JSX.Element; +}; + +const FeatureList: FeatureItem[] = [ + { + title: 'Easy to Use', + Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, + description: ( + <> + Docusaurus was designed from the ground up to be easily installed and + used to get your website up and running quickly. + + ), + }, + { + title: 'Focus on What Matters', + Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, + description: ( + <> + Docusaurus lets you focus on your docs, and we'll do the chores. Go + ahead and move your docs into the docs directory. + + ), + }, + { + title: 'Powered by React', + Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, + description: ( + <> + Extend or customize your website layout by reusing React. Docusaurus can + be extended while reusing the same header and footer. + + ), + }, +]; + +function Feature({title, Svg, description}: FeatureItem) { + return ( +
+
+ +
+
+ {title} +

{description}

+
+
+ ); +} + +export default function HomepageFeatures(): JSX.Element { + return ( +
+
+
+ {FeatureList.map((props, idx) => ( + + ))} +
+
+
+ ); +} diff --git a/documentation/documentation/src/components/HomepageFeatures/styles.module.css b/documentation/documentation/src/components/HomepageFeatures/styles.module.css new file mode 100644 index 00000000..b248eb2e --- /dev/null +++ b/documentation/documentation/src/components/HomepageFeatures/styles.module.css @@ -0,0 +1,11 @@ +.features { + display: flex; + align-items: center; + padding: 2rem 0; + width: 100%; +} + +.featureSvg { + height: 200px; + width: 200px; +} diff --git a/documentation/documentation/src/css/custom.css b/documentation/documentation/src/css/custom.css new file mode 100644 index 00000000..f953a02f --- /dev/null +++ b/documentation/documentation/src/css/custom.css @@ -0,0 +1,30 @@ +/** + * Any CSS included here will be global. The classic template + * bundles Infima by default. Infima is a CSS framework designed to + * work well for content-centric websites. + */ + +/* You can override the default Infima variables here. */ +:root { + --ifm-color-primary: #2e64e8; /* Primary Blue */ + --ifm-color-primary-dark: #2350c1; /* Darker Blue */ + --ifm-color-primary-darker: #1a3d9b; /* Even Darker Blue */ + --ifm-color-primary-darkest: #122d7a; /* Darkest Blue */ + --ifm-color-primary-light: #3a7ae3; /* Light Blue */ + --ifm-color-primary-lighter: #4b96ff; /* Lighter Blue */ + --ifm-color-primary-lightest: #63adff; /* Lightest Blue */ + --ifm-code-font-size: 95%; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); +} + +/* For readability concerns, you should choose a lighter palette in dark mode. */ +[data-theme='dark'] { + --ifm-color-primary: #25c2a0; + --ifm-color-primary-dark: #21af90; + --ifm-color-primary-darker: #1fa588; + --ifm-color-primary-darkest: #1a8870; + --ifm-color-primary-light: #29d5b0; + --ifm-color-primary-lighter: #32d8b4; + --ifm-color-primary-lightest: #4fddbf; + --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); +} diff --git a/documentation/documentation/src/pages/index.module.css b/documentation/documentation/src/pages/index.module.css new file mode 100644 index 00000000..9f71a5da --- /dev/null +++ b/documentation/documentation/src/pages/index.module.css @@ -0,0 +1,23 @@ +/** + * CSS files with the .module.css suffix will be treated as CSS modules + * and scoped locally. + */ + +.heroBanner { + padding: 4rem 0; + text-align: center; + position: relative; + overflow: hidden; +} + +@media screen and (max-width: 996px) { + .heroBanner { + padding: 2rem; + } +} + +.buttons { + display: flex; + align-items: center; + justify-content: center; +} diff --git a/documentation/documentation/src/pages/index.tsx b/documentation/documentation/src/pages/index.tsx new file mode 100644 index 00000000..edcfa6b6 --- /dev/null +++ b/documentation/documentation/src/pages/index.tsx @@ -0,0 +1,43 @@ +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import Layout from '@theme/Layout'; +import HomepageFeatures from '@site/src/components/HomepageFeatures'; +import Heading from '@theme/Heading'; + +import styles from './index.module.css'; + +function HomepageHeader() { + const {siteConfig} = useDocusaurusContext(); + return ( +
+
+ + {siteConfig.title} + +

{siteConfig.tagline}

+
+ + User guide + +
+
+
+ ); +} + +export default function Home(): JSX.Element { + const {siteConfig} = useDocusaurusContext(); + return ( + + +
+ +
+
+ ); +} diff --git a/documentation/documentation/static/.nojekyll b/documentation/documentation/static/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/documentation/documentation/static/img/logo_app.png b/documentation/documentation/static/img/logo_app.png new file mode 100644 index 0000000000000000000000000000000000000000..7a36d43ced7a9641bec3ff2ec4c0607d9bcb853d GIT binary patch literal 704 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H3?#oinD`S&i3a$DxcX!k>UaGFp-wRB`f1Sh zvr;Q*CQt!eNswPKLx@J(-EF4xl_vp3I14-?iy0W0Uw|;<*6N^a1_mZoPZ!6KiaBqi zPER^)z+s&52Z*Qs`ExQ~t(EE5Jci^ejBB<#eR-9s^8T}X zmwTfP=MBBaxN43skMG#dD_MCVAzpUH#RKP=e%(7eVNK(`6B+q>n$!6oS#clfn^?yo za_Zi``z-r4ewvwVV6YPkD3rZ8v7zg=2jBUGMh07xz|o?mm&`=EZU!T<63wTyF*&yQl> z=`lZqd0$5TG=_ay^+Eip4R(v$t&YgYG0SbVcVy_j#Qr_t*iYr`1BbsIzt&*$rO{MC zzWz$-k;QMh*D>yV)W5p%$4lj`19iWhvknM`8xMMU8L8I5TQRq31-C+gk{KG66*m(xRL?`a0!C*=|c8oyQZEbypuXH}Ut zf8n>=9|Tkmf8w4t-}!d>HO`Xn7XQV%k0x#T-&I@Nx3#`vw!hL%+5M|c`(kYEPtUZe zxOw-coM?WLR9a!axy2bF?T6po|9@Dam(co+?V#EPImrb=8Q*yVIJZ=@8T>o;faTUS zM!m+e(+f%(tMr-ANbGYibWUJS6wc^9$hl?OPK`>=jDL_we|Cg_-ImzD?Fp7yz*NMS f8sVAd>&u`8WOD#92wV!D45B<;{an^LB{Ts5F^D&V literal 0 HcmV?d00001 diff --git a/documentation/documentation/static/img/logo_ugent.png b/documentation/documentation/static/img/logo_ugent.png new file mode 100644 index 0000000000000000000000000000000000000000..c4358cedab5461ab8c6d053a3a8b542b99c7093c GIT binary patch literal 6422 zcmeHMYgAKL7Cr%O5*ZAT;sb4hfsl4oFjdh~LO>D9SU@WxSZlb^3ThRpVhsr-Xfz25 z5v?VN5VUo092{<~_!x(T2E;f-u|*+5HBw`EnDR(88WO^s8>eg5nziQV*wwE7V6o4> z`+R$!y}y0#xhJv}N%7v30ww_fyc6Dw{Qv+D8vveOgx8>ke!P7Z{D<55LHrW%w_7v& z;b1~$OkxZGU(3Cm$V50M?szYKC&1-Q!~SsQZ&3mO)Bc(e8M1xnQ6dgTyu4lRk zgc2m zur!p6*KRyA=bHs;h}oA=5MX)KjLB(#ovA*kt-D!7 znl5H7xD~v%mRby!1650OJGJgI``T|uA*9)U)(aPI4K0UG zP9|z7Q$kGrOpK@)qmX>4csow6sC(-zOjn;if~hubtgJh?1S8;9vK^g_kjP|f{pymJ zlwK$kq?Mo&qLkNCJ=JSv#a*S@G+E8g~4&;+PXyJs&bTZL{XQh zn;!t(cLrPj5~n9G)jjf?b2Sa3rv#e&k1MW6w4H?5$`no2pp@4fx$0#`65zv@7t7aR z;N=xqu*n`sa(gX|jo0DM$J0OQ+h;O46^fdLkW$X$u~95v+kzO~{MQ;3HLFtvu~Fy9 zw%Rz|hRT{XG-}!Q&82AON-l+&98k)s@K=68;7?(?&XcVRWxTUK_IjD1Bg9-@j76WE z8+yMZ#FD-b&HR81$0YVQKL%OSi_qRIcQPW^CBY(=A4Plr)EwEtTU?^egnVpE5wq~q zg?6d^cypv~sj+j@wah+J(f%fy02nsODd-++VJZ+%6e%AeH*hc5cFw<5)>)}KUzt;<)LMB!y zB*$Y5w$ap~2o>@$#8jAF3`w=s&t6Y(_`D2xcs9&PK@?(OV(^zImCBc2Di`NxHb-(P ziX&KlmCN*>ah=F)48GCNjVIKo5ZF}Bk!XmU9{GN@V%SYGUiCr@jV;diY>tH65U=`U z8nx6Aj;Q@G^uBU;LJX4;H98_uD(Vt+o-zoS>fB9mOib&A>yM^m`G5hl$yf>TFS6Yw zbXmk>;^xTEZ#|qkm=4YXlzjv;1n!8Xa;;L1sy&!;1%7dr8=vis2t^~35;wjs9!rSw z06OmdM6iuZ$>3oQgt;2m)Mjj4{l-g@xDfGJw3{RNEV)0mbew;>X+7i!zE7^be!818 zd#Vj`gmlz8D_D072J8H)aVXS$Jv$2PJ$%L%n)>2b{_2D1C5#)mv?6WY zx#!yKaPu`RR8hUVdIL;L#Uwtn4cjF}sc9qqbjupjO9$(vg0&^i)tdFNxp94!n1fUg z;dd}r*r_m9Q3`aBn(DmQ^Z;WAz8X@#`*V}jD&cqlmCFpm)&Ljc%qB|MpD@WW>4Da_BLZgQCUYsaj>dYnZL+K`*XoJ-G zn7}WfLGc}_lLwn85Q=n}v!CeDG)IEldtqO>a>V&D7%f3HJ=OV$;Gor7L3DDgt4Ewa z54sBWu{(Ti2sA(ZzHunT>11GtBRr{N@0WzcASRF60EzpZnD4<(Legi<(v+ z6d;B-;q-ZxlS;$PGhhtBR2E>@6nqSqm%-X#WK2+@oEuoZKt?`sHXIzzrGV=EhBy_f zELY$`-!@qH-7y^kpLO@#Kd)`Ljpt%^8(Vk?v!m`E@@bf0#Li{tiO64EHw^f-DQ?? zXl7Vd(D2Rz%LYhwPjHy4)XE{VNFe&}ACp;c?Y%t{K=hNr`~OhA^KL`e*9+o<>N|Deu? zPc-p47vam^!B(*yR4Rxjxv~~*9ZMFEbsQD`(B&L5jMD~jAmBNDVB9VgcK>iCC&K9+ zgs^>{1F((!tc+|kxJAEbu%qmK!Wa%fu}u~Iz;VFy9SDfEFdhB-525GUFaI~E}x z^MKA)>_5YiqZ}!W{CnAGF3~X&q6Qv2!%?Cf1*Vjah7%nA5Gio~nQRHlkwIl1W1yR6 zUK0$F*2_k11jjKrsd~{VRiZ|PgX`*pre$ch7@DqyrXjF7plK>JB|2KLpcbA|ku<6p zXqvVE%#Z}l{v4WigbctPVFngoy>!$?aI`?wjLoN1$&9HVHqH0uJIz0to;l=AM351c zOnKn<>c7l&(yzjk-w-e`&Lq&ca3dN zS^G$6&D~bV)6kknA0=JCdLorHbW}-nBt`z*4_p4TN&hc%`#URfE0Gn2OQEyEqATgB}?cPK-jMm%_YA&;kg@Oalh zTKnOyKhTqJneV=96TFW96~Ej5>&4u7<66h?y6;;*qlWh?qe$?YF)wGx^z7!n{`#I+ zJ1QK0de?NV!ipXL!m8MRzj{UJ_+)Xz>g5k#89J%KsFPviQweNDyG>|5?9 zPo3Z0zd6>O*|a$-s>t&fyo3J#ksvAb;(^`-R)KA|b#&}Hq&ry1;_cbGv$v&x$MLpL nu-ebL{u?>p4sHt{C*6^Ox95I${v!N+0VFI-iv8P?jJ^K?x}?F_8W1e7YFLkbWeDm@r_l@c3*C?H+BNKHZ!giu1U z0RjS2LP@9sQbTAV5ZZV1e&5V`XJ*a(d)K_P*5uE=bK1SnzR%ugpX)lWOpW!mf z!oqS!|DLuv3(HX&3(FCg6UTv)*)dQtAddNH>O)QdzmO9Sk--1UzB-n^79LK%0S~+# zS)AQH(2i0*_TG+;?mjLazBJY*b)b>NVIwVX#|OTy9`08mu4qRVh>PFVTk==$dU{{I z_4h5gt2gCUZr2~2Q%c>Q$=G@v`X|-dSdv0O9|3f&nNZ$U10>563$1!q zK)$>>c37_U7hnsPC)Xka0P*>+@2Y^ff8sYEApSnKdKM6uo&~Z4;`H5rn{LJ!PEYB; z+x(7>j$(?6gX6h?dY@x$q*F%RtFK?bzN^+A^kA5P-U*KUG`Sj>n3YwUmnYQR+S*Ye ziHSW1G}ictMkp&QxBvVJL7^6OF0Zt-2J7m+eE9HT;@h_qG&7)y=aC;>T`)VlEOT>n zU_fQ(-7Ln)hz&5xPVi}<-sbA>sVNyUwZEm6C`4!nN+#aEu010Y^$%mAj{eImsI;f{ zq)8IPIMTqdm^m49!t~pb1AJ@QaG42zupXP@n|>Nm``%1&RggvF_4qVV_3!Zz>>60?OAEk%j_I>i$&oc%{-l3oO}4W&eFw zA~$$pGwaoi~m$Ci3vodS0PP>Z=n1A;ij^^jFMbk|mW~jrTX_Dca!+ z$Zon@Oh#o{!#SKnu(no5tVcuMKPE&gW4aHQo;{$OhT~Wj&)#=LJD-v{L&v0ls!Zl> zYcfF{)0DgsXxV)h9k?_&)J)#p6Eb4D=M!cggLqQ%Hr3cspF!^75M(iDr1|JEMDS}K zESv$(r=2m)eBlWj1w6p>^zS`6rG$uuR6V!(?A&Dh@E)e$?^WEXw`F8AJLP8FkDpMBOj z^7_~3psNwjzU5xv`u!HyOtg(J^>BIw-}N(b`R+$Q`yvp3!Pb1cceLlHnPAM@K9$%j33hC57MGTWd3hx*2rHW1GfO7S z4)61G0WDuXoY}jw&JDu#6D|6XVcLE8gWTLwaQh?u5sq^}ePL{%Mr+2GFWxHYZa>yJ z#iY{i0oMIo`;Cz5Bl-SrJkRk*gK1T4D)n~@z(l7etnxCjixI?%4c9yM)veZKP+W zr?>tqNO*gDPkYzys7QTO~OG-7yo7>GlO6zV2JX*S#u zd8~O6h*?QV`9QS6#2&5y4eweTZ{lcFWaJ7%bhcTC>o8*9F}lA?xC}hJFZeeNhq5V2KEWqF> zV8Gco3CoiOf2AuPdKzgLUwhCndGg&l<%-ZdtLFqSv~pRY2 zs8UxoJ(E6Y@qPX$yy~ufHnY&5zK`EmmC|*lmt-cLlL}kE8Or^*+OAfXegSg=4RR`h zCYNQI^XPV5Lj*fF6gP!r7afFc>I$=Ew0bpMW^T;W5;yv3{bEbE)j0tt((j)L)DV@^ z3H4|&r}M=-;41=S6{OR0gL0AZ2NMSiSsiYfI@8uT(#L4?hMw6Ksj~=2H<3lEj4gBY zbu}(L4*vI@(iSZ0ysyeE0gP~J2S+}z551FneQT|Q=N=|V>os>NKr?G> zY}sGiI81Z-1Ll`~ZhvYEQx51XZ9sI!1Ux}^h#15qw3O!;_1~>irT=s;4^?lQRQkdZ z$wET+=niWb_KoZjFVqrhnaIqd66F5p79o$@kMY?|G|Dv{@`!Y$00a~ zU$hT}-17VI=TF|;SmAMr9`U}IW~A%ypN{0pLdX#_KT^E~a`cxXv?{ZAmC!lls7jxj zKABeTo3NS8E*}AfZ2?oaU88PvS!0b<*BzU0EJig_AyBF)Z7eBGoZK)Us3BA5-jQ-va5Od~iX_|L+{hoDHQUiV+WDz(F{fUoE>b;ao2Mgt7p z4TlCkih*j7(ryqe-KyRhwtYD?Yd&e?>AvT|^-+bjBl(is5n0=W$w{>ACe|iaPyWW7?&t+M zWOnDMWGv_gFx3UM4P8ICOV(1;tRM>wnBy=AMJoKXSuiVBKPG(Y0SQPbw@wOJs%5Q7 zqB}O*xVZcRO=@M}92%KLQYT$`)a&sJmV`|irfJ!}b9qz(b52okdBA8C_%gRmCIXAwk8=NT(hM0`MEB^eW!;kU0+tspTDx6sI8=5^&;{4+h{5_w?cZaVo z$NnQMbS;(pB0v96X=`jx?%3c+z3u`%or_8RB^}kq)b)twDSKm%h&LfRHR2ggG-&4X zH@V*d0SnAz^%9qkMqF+%Q1pHCE7{d!kuIZ%9r2!TXV2>6qiS6kLe*pmY$I&LemWgk zH1HFmQZPAQsqXY98kd^42S#3+X_g%-r$q@>iTR7P7>0j&rbfSMejF0FD*$BqyG1Q! z^^~zaa=o~1(vkRS3&&MEw51Y)!&{3~fkf0wwj~RVt3%~N4H>8JLEZ~(6Hw$0(ppu6 z7Uzg}>UAl7w6(;|6&iC9E?YHC<_7!l?b#&pI=iBDi%K7_1k{xeKaDLTpsbJsuVq}j zenJ<582P8j$|e>V9E7OK|ErVltD`=OBy>;NWZ%F5(`8Y;d^75_9~k!b>vciU;}QSy zLY+Pg<- zcLO>qgYH$t#|A8)b>8EWg^MZCxz#)kB3NfD(7d%Jv-Gu*RO_J5(7M~V%yXW`Fa52f z`%=u5WXHEna9tL&mu@yV@VjrT(@u6#o%X;S7lFCxv71n2tIC#)Kw>HhchwMtFD<`6$BU7$?flHY!q zP#^Db(PfJHNST*mDu~x^Zf?2_k2hZ!O8bN^#1(VnEM#Df9))wc`k)0eAv{f?2ygs6 zf?tV%YEW=UmdG(sC8_-mTG$MNl-WqJ?XUGgsUv(TpyEG#)&gGAtfZBw7@WqeDw=GT z-(g|AE0x_vKT$NODHfsrAnU@G|8V=

gQ0 zS*7WTW|gt9jNIXweDM-*9IRv|_u=>{zcvucHCAXiTwHVw`{-K}{d<&sf!Te-)DUmc z@&i^Lu8k@u>9nE%r#ErW?2~+5P0lo4Gm3s5HM4pzuE$KTsmSw~n9y_zjXC5KU<0c` z@y*)k;viWMvyBPg7JW-2K>S{whakWcD_|YO0~QZM=GtaYpR@>k z0uN_vuJHR#y@YRvz_tx`*(HRAa^@-{Hg-QwBMQTQcWUv7siKDD!u3rJKx>1 z^ME(c4O5=8BYsON z9+r!4zJ8-{xtS6MWPq5ER`-Z%eG8fZ2agIEs;ux@^5$brI&^sU+7PE?=JE%sB&z(D zg`}`9A9z)Teq3v-ki>?RRS>9V^ zEa#`A_;P!VeB>RSVjlLn_vV|iI}s!!mPYu<^@3*0b>+^XhhUJQR(IS+$V8}bbth%hd1 z0+va8Y?>x_S5^e`J}4Q_auIH zE*kFbOF`1i`aU%ry2s1%M-=t^%u5$>Y#iK01}W0}{8R8NI|gZEgo0Y*`Bdz7whvh+Lr@p}1zi{E`UKN{u5 z`7?)Esjfwq2+T5glxbLzaZ_67Sj8k$`-+!q-NCT2x9;jbfL48e^%rgSHR!`!&D#V4 z_BO5$q8Dd@qtAqc<-#GB^>2jB{XYR7{(mL^7tr^jPCbP{r5$2Oo=1W?ioVMs_c33NOq->%2--g+#$Gpl`RupZ+G5s zDKl`Or4?M%Erk!ZGK(A*v^zWXdm2p6zy!OGcc%Sq#W6rNbx*aZT9C7tJ)*csP88j% zSfPy)%`HR>Ngv*fihJqe!(K}-sMb_lx2*IWLVRiOTVUDL;rD)4<_}R4B$Y10C5hss z;|G)1MhE8a)ZjxB8h2;YnJTiHd>-`(#@LvN*jN$|V?mXvKnZwkvfM%XY9fL%&`f*D zNyDkMlXhdrd#9!1>K^_WWsZ(&`C7RY6x1K2CI>CCu5*|4&zaHO-0cME%+3>g{w^1r z9YS-fb)(_B*wBI5L)PbFBM))SeU=K(lF^QK)0>+{kkO!$ci39x)rGyS6Sg#UPqKA& zB(z!9!)+jd*O&D1PvMFZ`}QXG!@PZ+^PLYzh1-bWbOHo-cbvC>r-D_6JO2DL&Vo6KnZEe0JaPc z7jx_54w|CiY#RjaXfN{SdXpcBlPh$BO`KOpPeC8bGM;*-2F)^!t|^#%7hLpr+Fn!!I$5uRyK0-yOAY z_f6vs=xVQU?2rrF-8#?Tm!rjghEHYV!1em!ddT_H5)6@*#|w5^mV>w9cG!0dbh~o> z^@5-MX$nT9@rn!F{Nghg+U*T`B4Vnb);8C}Uy4r;ZWeE~L#+KD4I@Y~oRO}jcGdFm zGDLe)wSB^Xbb5)H#aNF>9Av5Q6#kh=h#92O}c{pp9CtwC84b(E> zvL2sbZC&`$0Ta?tc<{8^fBvHFrPx4!EfP+iI?DyQQiY8JzCd};oARHWXzgf-&E6vJ|v&euC6E{0% zi%MEvnXMHgOQ=Z^O3iMII*i;gKHGz{O}V9-lI&>FjO<6S%D_;V(IzY-)gqTperwAN_Y_bNV; zC=MJPmX*&Ie4X_8o|^JBBmp0cpO5dRk0H#n-NnG3%NERywlrifj3KX)#5tN3OoC6G z1W3{+>m3zn%G}OrY0HmyZ0mfL*V$J6h~MQwE_cU+lrvGQU#J zJG*WFX~3O`G8>&Xu5&jPsKQdm2k&;U~vI zR@xFshYmxf{L@v6)8X{f{!avW#yM>n|??G|mal!@B^>^)REOT}#)1mzjR*9|!hD44RO>V2p`sY*(P=5(u+=^B~m%#~rZ3sD_ z^Tj6s&)PU5MYhf;vdiQZAGy}XRZ=i$Q)Z_;-!4;5`nx?o;6U7nUsm6MFx?TO4?I}BmMGQdNE1sJ?W$yTXPY`WTIggb$9YzgtXD%pu^VoOpDG-@e<@TYG}^oU=rnjaA8~+>IHx7q zCf!4zwYn~4$hS+MF>5f`nNQ|b#dfug)dLp#?20evz@3H1$6p_G=_MlPc0lP>W#2`V zdGWJ+&l7nZ^&4%|=wp|t_iD9F4)z??)USdK)UV)YbVw=f5>>1CYWe({*UJZs5}eI=vEstp{s(#4VDdb<^7ttqB~V{obzL+1S2qF|6PAIKvL@Wp&fM{b zgnZPxyUOQh)Oyvicnpi{JuNkEiX07AgeUgp6mf^rk%~y%P=b4s7jF1%;l>Kx=zDF~l2ls6 z^okI!4dkLgsx>wL@W9bk%R@x##$Tz5@2^G1t1uEm@`EeA<`sDuJ?XfRIagaG?ESN6 zzM*ToDt!C5T`SXu?dE?(%Z?odU6>Z#8*M_(=QI&63=_bF!aG^-FV$*t?mq|RfA^Q- z3>$srEHPet$6iFTKRNzt?YW1L$%K(s<(tt?Jw10opqFfiE5-QS%=V?rnqL=RM}Iu^~}zJ~O$!{}k}Z6O%OfY-COCry3kq(;s_Vsj3BFoWl|Ef^x=fqc4k`nc0=TdgaB(C!=ce zUb|zwpYSLNVa>Ny{W}ndWa~G3;!~#Cp@@6` zH8~^MVb*Q5J{$C_5uf%l>wXP@((SitvT+7!V~k4jS;qZC>AQukYI1+yHhdb+CSI9u z0QlcdfS-F5wVC1NbO z+~5?%tSM!AyYJ5SZDQg+X@}V@RBg9~_6ZMX6RV}z*HzokeX(b_Anq0|^p7xn0Z;Bq z=SjEBX`}W#P<*42l}0*th_xhC_rhi7gJ89MxoFcn4GJhIJ-489Jm*%nNi%QZ0#(ro z6wN=@e!Z8*pnFrXAE8MHpKjFeH~yi;6|Y>VLf1pbJ4b|KyOW*w3`5mJU$;L$ zTN+MD!rdM_S7nO@d%V5=K@)VfZ1dnYQi1_%N9@%KE2mdlfR{r$ca@P)|5O4-<1Vsf zUG!HJ1e|`Hyh}ZemRxItXJD;94T_ce8R#IAH8r0nHu-(>ZZIe9XKzp|_L}NUM#p4L z1nL!xHD-clPRGVc+%`QMp=DkaPr2dRv4piwpe5PX>b=%&Dlkd8n6VuWEalH-6^|&5 zpFLoKjYHyNx8MmkLJN}+4E$mKG3sF3;}Kcv#)oIo2N5y-FS-j*&0Xh|Kk(ug-y%qg z!eCg{PCZ4KA{Vf0*=e1SyY|^2v3K3+3@xk3)L;$d;IdGd?e^u5=5XAr1%(X)YwRQ? zU%9LS=ek3lQ#2XJ_2;TrTIXjp=hDmC+@Mz=27U-+{`4+3t+6_tKV-?emXFjJ+v&40 zJmgX3-UNR@m+5;?aW^HB&$gu$%3HL!eElFL4$0&3zfv?gSSUUqHmq}*xvAO@m55h7hu|NGoT;Nfu6pHluTc~I zZZ)-~n^JV!BXS{cleCgvYHGWz0PNzPU8IS?2%$E3X$9_T_%VJTPJjz$vAO?W8I8Pu r_dhPY@%+oKYyHcP=KqzHtO9r8D$`EMR`3GzV$s(z)-Kh&|Li{i{@`o7zKG*F($M$^&BRW(lx%a(1-jb~ksk zv~+T}arW59?U2GI;=D^F<7R2F%G^alK53n<^c<=s3_rDDFLPkyey*6?8`4jS^O)k~ zAKXVL5hdXJ_bKomKi1Hw6#dbsp7Qef`{1(gAE|>i-zv_kTMgJL&97Z!zQ6oyo;1c( z?B9`HOh(pcJIu;D)W^EyHxk~(M=XOaCqObAfyBAHSR-Di#Rhx?{Sw&V@c%*Myo9-;Cw}XA>2WVYh5Yhs zoaRZ2Z4|8o@uVb8HksI0S%IK#6K?4R-c3*-tBORsuYyrN2ipv~__Nuv-Nn9I9eQTH zBi{25e%nQF78C@niU<}NZwqQiG}I}j@_BHaDFLTny|cV`vu^P6y%0L!?Zm2TUt@oI zv{v92>UCoM%=ZE5iLm<$O${dA$OOR1+SM=(BhU~tg!)k$(6j?_>~%HdQpvD z+CdL8hz)^YTAM#D6%=;gfM^>G=L2RBD28jLhz+lC#y5FUG#_2R_33Z}H{& z7Gg;sJLrUs(WHPsUT${h3woExDNR_1@rQi;k+JeV=n#_UPSYh?zR#jO{nMwD;%ew4=rEyx)J|_X%5F*GEjbtnQ=9v{bFu;vGF9>> z(G@d-2{$M;gH`;z$}Wk_g-+McXw(-+V@k>E*ebH5^bdl4k|pUIWNP!ob;@!UZygeS zqM^Qh!-MMMnbs%2san>mDEn-&rB(hC4|&P!x;qJ}#l)!%1a8O6)2U~C&PtcC4&vwK z`M5P>4Jmyyw0W{Ue_m>5YT|u$pRK?OpXAyrgJ)3N)q%FK8BgEdY?rXjH4q505!KA7 z8Q#jMJnJYPGE~#aTY+11oD8QxJ*xE#o=!G4F*vZ+LH60H`_%^{lEp!lLCsTeYhH7$ zS>I;pmul4kaAB`{3MS%K35wK*r2M@y3K1#@PX|@oEM?!g8P@1;F0x;q3zQ)WMv#Q< z!x>Nhc!h_Dj}4{?AZb?_)CO?>oaMISdWSJCOAP7n!y3puH4lF|dYEJY_O6{8rmQjU zO=>5*VU7_mj)FG^zkO%@bvyn$biVBN+B|UN6=ZjeN{w`E4yv}@8SW1n@(MC-McMR5 ze7fOdTB(WsJ8m}3xu~a{%z@Wu#>Dw*`CIuif+C>r-P8(5TWlux_g>3dTrAqpzHP-C zSm~C!TOE~{Q782l`po!ne|}`HR4wOO0XMDmxbJz=BP4C5OD2??e{9?s4e6(k{ep<26obc8tjW zl?h=gp16pu z?aM@*iOC=uyvRw6O;kEJ{YrakLRTk5VnF*EJqY`8n|eaCLE5JMoGu^-NB>YbhLVCp z_6zuedQCK;5t&jwdjJ&X?PA-h6V_4X^VJgNJY9+OK>wL&l`9zk7yO9zvnJ^w` z4~xker1ODOf>yqinXQyCJ)Qn~=4M^2!R)`>K^KZWV`g)odPRUdiP9cH~DDr`Wx({Rf_T z;p3(m!o#iB>nqvRXyWo}BA8t;;cv$MBS61I9IFMp*OY@Jr%P)0;Iq7t_CS-wcB{H( zlAK~a`$f&#t>cQ>Udg;pR^rmN;0A}SllXkaT1rjzw`CrR30yUcW{{BuOn7tsi|?n1 z2lhWVmYd5mN7jjS2|2mSzeQ05vX8x@7+Pec?t$1@*Q9cQN6FujS_jdI)%xew5yDP(C7(rjOu9zxjA`hE^s76_+77+1z^=CJ&2y(o1+4?b zT{rg#CF9Q37r(T=bPuu`n$5lEXfi?WXu!6WP!^?+KWR6}co}d;6CE8R6=>SMpALk; zE`cD-b#DWs#zea@vvmddsb|ZB%IMBZuU_>EE&=T_${2O+AIX$QI&H!b4wvyK+zb4g zkoT7#_&q*t-@9=tg-sjWA7BXe^1IHI>yh8ytFIOMUNdsrLZP%wN>ghQ{J_Xf@w{Am zQR93$y!m#5U`HdrrhlnuCEzE5P_l~_5SMX>EOFEVN=tjh>Zo|fr+;HnT*32Dr8=YI zj+{coY97Ta1)b2T)&+&w!uA3u5P#h^N`vOc-W14}p9-Dvh;~+P!?D)cxuRDqn?&M7 zWZWb-7<(0}tQ$xcY!M1mw4%I$&&RZ#&vsxn=JZ-KhrvGn2V@*`?%8yuACoh$>KuO3 z_jBEbIdu5v!vb1%7yFD0qtW)dik@gL&siFN{*HL~`nO*+4U<_QXLt9Jvyppht|Rn3 z{tLYRyTGx+oF@n81ZyNoke6h-62}sI9HaU$IKlO8Pk??ziGS+Gk%6E|Z2)!gLxq&@ z=*z7PX!`AStqrCrdo4^{CuKuU-9W9+$>i7KJzmYw$|5i-!YibKFz$H|fR!`cbHpOWay=tAj4$@sm zHH_UD(UeBzbXD|?PpeK6nOqm9w=Ip5L+)>x(QF2A`>m32_uEwbWJGJp!byOlo*&0D zeB74<#F+Z{EP5FtWfNK{#(c}O%l9`!3`2&?(@IiPQm2kPTS{6%39j!4dSXUb{w&f= zz_YKJ*xhT}Ke?v2(g(@_%bHf1e`vv%3i<0XiMKZ6a`Il44h;#-_6GwyhQr8JO9Y7)!q(ZzLqri4ImxR4& zhT#I;F1qs+wO8kIiRB#r=Sr9R$b_UqZj-hWysig3U8Ky&YmFk=vnrS5ORqAoEPidN zUWU~ZQb%cr#N*#Nb7^{NLaof3Ki}`U>S%`^35%?sOzxel2?qoD-r*Wh#1|t>V*@ew zdO_Lsu={`Rg$$#Sh55`VsDkeIR;F3PByg=Ntv1q2u{aYw%sR_4soiz+oFd1{!enJ~ zd4{`2yR7opZhd9HK9jQP+pFY8R;>gNo@F)Khmkso|47t1=Ex-EPWXCpHhDC1T=?oU z%MO3S98VtF(HA}OP1&k-)?u^iG0hrEC@)Nh_5_iD7I4o9DVO>qi05G{ z1``$^5{|>nM3m|oBWfp_DGl^Ml@^G_-$8C1N-gj`&G4Ne3ccCVjt1IzExoxG)Mvub z;8H#$vOx0og6JjWZfb;ExbG}$+nKV|)% z9Jb5Np9*DV`}S?hg(d8_bK|%i)l*tJz4>@S#IWy)9s5&#T#*fXT;DmyenQomlvx93 ziV__=lNx)VqgRTDEL+Vl6u+I;WT*Qt-|A2}jGN^9>OiiDiZ`VLan@KM`$2%lU|Af? z{7KADw))gu_r8|8!%)5S!=<#EjpDcP(ssfY%GD zd3ug`*;aoYHmnv~-wSssq48={s;@b-H>n9#PCu>4>m6;ItMlv-b{Yp85`5SVd93 z5aSF5;{<<2Ox&n>>6GB_QCvgcbDW^m>fy7v94f4RukQMaZJ=2YL;|-~yQqTdBA|rn zfy%kdh1B)0g^ne$T|pXY`mFnu>dqei<3#)e8~k^)izU;KNBP^NEo*BQXxHeY)}Hx` ze@~>DE-mbpqdock7o_OcD1lgd5gITxv$!`||mJ!LU{ zE|4T83(Q#rO>i)Wpx_m2YkhaO&nT_~6Dn3=q|5*n0-gTemVIrLl>oHG{nwZP&~XY} zs15mVa{JS{e^7;lHYul1wO+Lr>Cz#<9S-CBwK-a2%($mJeq`+5XoSQ4Y# zBm|Nk=(0QBOk!*&k{9`lXs+O&iW8U6N2hunMtbLz$?%qAF*1~TH|@?Mx1wxYh32}j zo$;AFGumA-S+el6V*gB0AIuLH%Yik)s6&_)>Y{hIW=*oF zu`!>+sJh(xDbRr86tiXI$(fPMC?UKPSSy9dOB_Oo|4QsPN;hLAo$e3mJl&sbr_)&! z@c6j&mR9dFZw?Ww-L`SQBc0=TkZH>v1DtDuSG8jN!2aTJ%@ZS_pxM}C?|I+~jO-0h zek<+|l4G5pc|U9BndIHdi%0^LS7W5UzeV=@OsOK`3vT_-VivFd%4d}!-%wlwiy3l^ zM2s14Z3-W&O(o@Q{uUqc*4O*usok;qambK7ADcXmzuixfF{SWRZ(WICVfKZRbR}

}j8vxY-cyHjVqh8tHVx#Rf&Fvuw5o7$EeDtwBPm|H&7q$!Mp3NyR^C zdI6AoU}k={ygaHt4IgM`+~n$0zZhViAs(}v&p~%y9l>PpiUb}hMWc+sKNp4ZzAhC9 zE|HgbcKr74{xU%+eKD_u%EoB}!QSIgRJy?~y&CKhgd-AF&wuR6d*gq8zA?Eh8+6(` zBh$Q@*p-fPvksmI7B@a;A2`vEt=7-V_Wx2*00prZrnj2Nw!KwP0It_Y##}TkG2D>K^rkZ@Hy*d$~%g)Hg~6W8t!Y zRT`_ux#Yi)5L``Eu@k>00jkfcrBPG&_x5TRb&{tSTd?b@2CEGrHfz1u5OMrxywig@ z{My9<%(34Roy(4rHXQn;B9#{8RM2#Jb0gfN^f`+#hU`(P3cWdT&p2ZYgQ_*L%(gET zs`jGPZmc22I&=M}j%tNIp6zze(iTj+czDmKK;d|##CBjVJD%)K<%%~cK!oE|t&4eg zBEmR6J>c+NM*kT`PP+ZYI)=8aBK@*m=nbrG++b)?DH-yyB>ntSy)%!fQ6f!j<`=?u zfsfYPKMajo3F9Zy*2%!hvHQT-(voOd%U(YH3<$Rm^AYj}r+qPvKwrg&2$Q+oO~#L} zi8}>CBJNgPL-IQs``<&D?2Y9I+qtyy1&4$fpJ|^Rh!I*%D(9vS|GV6H-WM0#laOZW4ih%3A9hzf7OKwC3&b z&{L4K{yAA@USloV=Q}Kkmvy?SKbhacZsgbF9an20BBBJgg_aQ#z?3i4;=$rZMDrVM zdAHg(G$P8Ub!oFdnRL*(ZVKQ3mthyMhE&cEK;|_zHtyztd^XW`cD{j16b+3MnM{^; zonODCXP$-?RT#sP>ETn0aO25Tf^3ag%NNsmsaN(oxy3@O@KgQ6VcI!q2Siy}O`nuHq8r`RIj>YnJLBdKtZo3-AEuX={PTS0 zP?&k$nhYz2?j={7rL7n>iNWIc)3I$X#9z=;iI|#0B8p92FXrxnP|Ns;KfNrD;yb@Y z?5BHEygCDd5$f z!bMXy+{JVA_jjpz%Q;8eyT<h(>ypvZY4}SCLB9*T42;oE-B5XYGy0wx@<}nP-=~*n0M@QAGFL{ObNwAy zJhJsCJ^a$fJ7RoX(pCq97cxFRu}=T7qp=e{L)7b3z%Dc-L+WziqI3I;gKmqH-N+Xl zrL}|;`BeOXUFZ}7sTr{RJ%VW2kD3;lS1NJcV`%+>>-}`Z@ujr;?ij?x8MKP#rdWII zj;s~)+BvqCV{e*1j|fMW%+#BdB0{JHEf!6^e=7s9pdfGr^i;ig--g~PVeJ^y?xZZV zJU@4b)NoeyE{&*dm)cUnv*LYpO0gJm1vzRq!(wf)rJJVa;BMz6n-8-(MOr?YFQ!1->$aMj?T)k{iYpkaWA}zd9BKM%$ z;3h2vMYrigT!0(JI&Clf#oY0+B5kA{G<^|0d_;DZ4kd3^A+mBDQz%aIJb?@u2k0Ug zIeq19`BhPyVDYVGf(*~@-tjR6`RRHCOLK>1m82`zJS(&!lv3IVm zI|62CQjfckMe@e!ATGZH(3t7^4i%uLolGjUZn}z2;5Lj6JZK(nmC}ZYNR|_K@Y|ih z4tAB{Y7t-4=&Q>8J-|5vQOnbGJ=a{5;8xjfXrid6@qfWs~LX;+5?D~)m|R1n%; z)=Bv_#7OP|Gpw=O4k@2#uN=Ri%Q2TrR9Ax28D$;n=M3n8L2g1jX=U>U(urPD4RNdc zY)pBza&7ZU$)DUGtSMkc7*R6|$7~OEvAgrNS_$v3(}8 zq}1GcTP-xw+{h!?ZNEFn!VqzUa4ZKsz2oj=W^OtYV29TL>^ly#k)H6I<^t#P`>i#1Cbi-F1K{==|ld(bvokMQZ^YVbENbK@KHQafL>B2`q)(mfwy$bP&X>U#I7H4(d zTRK*f7Ta$sNq@(+9$>3e{=Dneg8jSpH+*qKbHP6~G%k6If8tOaQ8T{_J(}s!=c~+* zXm%s>jrOC=fL+(ZGmz(D8!;vo62?C7>ivL>Iqqt$PY6XcTEbq=wknqBwXjjo!?)u& zm);l)<;b+?V9M7duC|ubgDs9ZVJ%k>t=yax+ejkm#t*SZWfk=;Won%(NA-BnsZKKJ z&v=zNkIB-gZ_N7CelR*`EzN*4b`EwCaTQkYz*_cS31)Q@72umI*%@1!$ zqWh$%gzpJ%*_zrpI_ZfR;&=)6T15fdH>t&|Pk=*gb80sXH%4jb^a}@5jR?_N#bX_M z&7Cx)zIb5PH3JG-$GMba#AuSTO3yuGmAu}|v*)@z`LsjF+2-Oe58-f3N`qtMlk0)f zcX1vL=f;KLhv19dz4d>CQMKlQ6&#mW*q!vgYPH%>wS?QxU=OhC$UOHm+eN}~hf3pp z-s$FgBEdYls1Trl!aUYI7*qGrPaiJ7P{1$pI->h`Ux2x&sli|^A*Njs5`T<6v(Ni> zC5tW;O5qBz-8V((*7Llstm>(4VF1~Mom|e8-9kZ7wu#=`8z1>TH2Px#QwE2uJmxz zZX`^M?aT%(bO)|S~Fg-7RNa27X&|#HQ{2dnX{<% z9_Se$?+*vb4y0;*CN>ftwyo|P*de&6p=PajTeJi1%;+#n+Kuc_R(d`!V97$1e@$hQ z25rq~JPtLl8)IC|RFDgik)0z(fR!-CCMkhsBI$p(Ey5L{&;I=CIbWc>fsah+8~T*A z8}Plop`n<*fPd7XsL(YI@O@IxdzDD{@3`Lb$?7R!`k=%}6aYB@wLqp#B+2_W=tH(+ zeq{;1vPv^9-(0DUH;k8E3=N!o9@M?vQ=r@zUrv%(Y$J0jY^Ogd&|mGWEg>{7CQBYD zmd7O@A?$ZnKLvIw?WRjmOGr8k4Y+QrOBd!?G0mye1+}Y^SeB>fQ{WFP$U(gZvx>j{ z5sx^CgHR7=<)GqQ@a>m>DvN1BY&L$&9<>xl8bWI5WQqgQ%eMn!?HAltv?qJj<>W&6 z`Dxu0kHYO>Tja-rj*bzTF-+Vym-yarTNq9D2@cWc`EGLKG@L&)TpW`d%xxK>@Aglk zrj&{u3Bz|PAxk%@{oZo$#6oq>Hc_jx2PFI7ODyPv-+Lhp_JMg3blTZ9D}mEKzK%fc zaT(z`SxUSH>dJ71e4%FRI%6qE4VTk1nv*ei=;kWgVRIIGiul$pBeaX@rdik0te+>F z_{v(=a)38kiZod9+=b&c*IuFmO?D>?ohvc#kdbwxf2s|H96Z4F9~w9cwkIwRX2p-Z z^rQfl7O#ne7_lw@008+x-K%GJ7vTR=pP1H#JKvl})eW)Yvm93r6-qp+D)!&g$Z){I z!#jVSi#Kzn#6ydjMHKRxiUPwXR@J&sHGhGTb{D0tuOhk7(GOqZbA^~bLz7}XRoq7U zI&FR29h;vB!2H^}=8IxT2%**lgpz-@@4S6dKPW0{`uVj!At(1d`13YP)b*XMt!)Am zcHb5cAHPhyRFjZ|1a!KCpm_3RW#hMZP`r<-d4MvaJIbEd`tWdy9BV=qB88d&3oo!m zdkL=4tCcGQ!=?^bdqA~TD#5|QrOIjigruY?$bfeCqN1Xm-{ItQ9f2KBo;-O!|C5?Knc{|B?X*)>(Ux}(rTYVMvsZV0s9j8$b%dy^6&NRB_Q*!Bk z|Ni}Dz0KIW$B!S2oLp5`SL@f?C2MMGPL^u1%F4-YXpTz`_Kr`k7Yp4g4^zS^4kmgV zFof1<;y=}*A1MGX^C}s_Dg*=sJL81|B&laa3=9lUA3x5Wnb94}5K%ikJlq+}?{QR3 z@1YKfDc2r^8p0t%HDV<$pIEx&KG!&Vvi?Fjr^Gp;u#3NqJ5r?y>bffmz88 z8`aca+4}t>d*CULJ>t}ABJ^dcIJJ z%K!G}Jo6ntYq^e47+Xm?g}^DQUHQgyu2fL(so|(nyrsW3{1GGiv))~KMI49Hg$M}= zo4Ku%n>4U}d)KQ|<$9e|V(aQ}UOCs7EmwGqBy4#=;;jgz|et6ngn{6jM3HCV8 zO{WgjNext*|2G~hcEKYD-WX8u64?+KW^B-QMJ#7 zJa@Tnwi|)=Mr}Uz`gJz0_7|B7>Ernk?_^|nG1@2zaIu?98Ml7j=nkUHc~8}lH>be8 zh0;cQKgN!dqHqIgJa%oA1~<)uGv;XdvPysFNOfo#9aRqw5t&8>S~Q6=0#f+^YeX?^ zCN#HDU)XD}AyY;mXV=ViU#Fb*JUmJzjfzs#e)sAWxcbv4D~ce{(p$S7Wd(6>FPB~t z5wCytad6SQySM)Xf55Z3aht!t2v?&}E9(x=#S)`IyzlhKM>5L5H7 zO&wcyKZC)2M!(e^N4cKu>aFh1*2?WPW!{pIQuF^7_1GF!S&V$KrexsU>Iqg_oox#5 zo-MEKZs-F>hXC7!&m^2|li}Ps0-VSXLyJz>0Vv#qM8T zPg0V&p_Z@d@EGk4G*TS>>*qPjnNg-E@L2_U`Nep)eOPQP7&{i6fhLzYw4w%b zxt8}V;PaO7`Stz8<2WJvDQ!Q$>!HPH)W(3Wh|=*j{b+3#N54~TS-&~G{m;f2j-6lN zQTz4U8Nc5e9H=dNpLp=#&bmKiGE$ z<;Z9~{x142pO(J~x+HG?Pjh{<0Pr29`HJYMiom~ARGeO~3`!UzxYr?Ehe zd5#rX4%00TY^!nw&WrdNgbylGPBR#`F7W1Ls%i3R>o7!3@r;JiyrwxDWO5u?ZoTHW z?BDwHGe{&*zA|m(KN#!(#!?rHRv$QVFEJBY{$EseW%Bi#Y+0;_)?-G?{Pt&~ddTiQ zjbpgA{V)IZ%BwE!N{T4Qc*@!h4g3LM89`pUYeyXcS&{zy>XcTwkl`|Rn1g)aXH}LNNi=uo-EEG1%*a<&9+9Cjdjp#1p&)W=a(~wB(Nwm6;96T zbYzU3q_XV+0Y{FL1|x7-!H;{d>`s2?L(iuPZ&p@<5@6@&sh0!bbc2#IG|_Q^wb1AJ zXf4@$7hO%1syG zmPmfC_4S2{JG*O^NXcO?tv%%!bl&046o19nJ_-n>DuO4R!?I4YzYc;b(Xm7upo15hH z^z@*ybJUvsal)d?i(H709I9|aIQYHibjv?N%BS;+F-z8DT*jet=;Mtgs;&E0R*&S@4) zQlxIL-AU-B7AnL5KPWy$k^&mp3l)>cF*jF8TA8c!eQ>E};Q}o#!0^sFx{)0#kzkS2 z1wxF)Og2HmN#tw54`wXO`kbNJ=HrQ#9hxLUR#sNDu|FU^SnhS_VC{r`A}r!5dmfg3 zz%9d~qe1ZuvQM#OtAQBsM-j_2HpSO~u>E^~Z* zJRuR$1UA;FVYpPQ7`tdGW8-)XSOK5zVcFcDl+&#-87%1-U+Ic;_cw~9;+@Ks37yQB zk2N%*0(_#mOVhA54~LnVnYq>f;5Ba5ef|3N&OF??vH}+%nTB;c??k==0K8=s6(g{G zr2xx7pjbSCpEeY$W?b(v{=ATaSPTQBYo=BjBFVF3K-#FqF6o+_Q{M27uD@;C2w z6!)UAr-QmWP)Yp{4gh?&4f$d&J_(v^kVujENpqgGXy@aRSeB~$hXlgQd2MJF44P8I z$swpdCPc_7xpwX6#CV$7ef^M@c4Gm&#j@c;&YB%!zozQ-5P5k{c8{XPxu2BWG)N?Ihbvefb^*^sjWJHiWo$Qt-E}vS$A>GkiA$NjK|KKkDl0r2?tLV z*r|6gn2mYe1!l1c&r1(gtCpNiatkf7vq8Y;vOmoC05zvKf|#idxQRwl3|gWvD)WVr zMKv@)mL3neaUbxxH3<7Eap|6$hL}bZhU4B>dms8oPcMC|YcM{IPdBjx&yaSV_Dwt7 zrn7q9-DL;yEKQsywd60bw7>OT5L}mwkc{*D^v*>I-w-Xpg(Q>X$(#0Rv-iq8%BfBK zuzSJU3{ zp0T82Y@Kb1_+t{pVcJOj_Q~GGsLLEoLCj#(OzV9lRR74z{N~hHros=}s(&8?)S)wM z%9Qdg{etl;{z7@qQ~0pMuonR6J-_oaTVD9j)XCujF`v_9X>C+R;idVWAqg?Y7Jn=H2v1ia4f@k8R@;@2yiDG}o%Q)6rV0w|na9V%M6W3Kh{(T7^;(UlFXO`|ysc zjhj!~+3%ruCaN0!9KS$M4}$DmEE0ujJ^PzjjR}G?N{rseIT$lfmK!BS?ah1iE)!hv z*W0g;D7M$e(pBnx5bii?ZV42jxU@}cut3g6X9P<47pX#ERuf3MF*%S8wyWWWm@<~tQh??Dih=;sI z)_FwiuY~pRNg^Yt&J&lGwfFk7ZIitJmY?0fC^J(HU3BIn0=&Ha&spFNvr@dy&UqZn z>cqC9TSa{9BAH?m21(kbiytXc92+CNzHs-i-)@rpuW8Cus-K^9Mtb=lt@+MOmf4t2F zyYXFgTMrdGFXace(dM(56hTqu>xkbM)g5?=P6o|oNz-kYvNdVu7+nkOogXo+ zSZ3vknOE}2V!jVWZP{+0AoJoI?-6fGz?%R$EZN2TPb9H^=#i%#n>pe|g{X@vzh=ET zTC(f@w8nb^@E${DvYH$E+Awwjs7pj<)t}ZZBf!d&umm*Hp0`}mHnq7WkWtB3DzkkR zX>q7&QM&^5J}3|_RHBxMwUhn1;!|FeTCQV)^taZqbP_KT1?M=D1L8IhNy^@|{~6t= z-t%1Qk?@@TTdC&W)^thaNhVdQq9td4Le;@`SSR}vF^?(*8>^?ffQNiKRNEPBBQp54 zJEipVJEfRGE&hRR{8d@~-b-`(ojC>-iKSwameF0{iWcoDdlkacZ(X@EEu^Rl>X!Po ztqUU?mt;4eP0S&;HUyxXy`4KWq(}21ptg-0V>W}Huyoa#4p+GBs{>MCM@GQ zs@S3lM$#@bsP*3i92jEx(S^>ZFe8#*3(_mdmt5-0uaq-z0EYB;i}_T(ZCDHAr}qy4 zpT+LpY)>Xks!>0V<75zs{r0JPjXla1Cvca=;vZFtqxEJSzy|hMN!&it2wpXO$6ZMZ7(1I)OgagDA<#$Hd|m{dawSY3vFsS5g-o2hXt5fmeVqH!o@y9 z5#L6$W2dQ|4)CvoDxV!*y_HHobl5Wl`db85y&3w{(D3dbKdhF6MW#q>&0X+cv!Ow?1Xj#gskS5L7Du%}=#>;zdHiKN#U;j@>e7;cf&WiMB`d zFG8~F7O~fWmoV(C!pe^^E^| zZ~Z;z*M^hnyye~79vqyUN|ceZ#>U1q`e|Nd*awcAN1aJJ8_SnA`-Gzx|^z`)H z#IueL4|5x_{rcmiT&K*>&yR=mu&JTauw^~4kq^Xz&8EG*y;7eWo5PKv*lY$;XT`HKx_%FWX%OdLwz1AB&v!UKPF|4J`xT zZH_m-cY+wD(pUAfzrv=71Fw56SssaDZ~PY>HI29K_w63H*3Q>tM$lf@^1EA-=w05_ zvk;P1KzlYblU@*9Ze{lqRxHTU?|Xdc*BmUNyt`#AkZ8H4_yBwGAyc%t>!u7#+y$?S z6Mk__t0RkO5!P!Cj=TH{Mi zy*4j2`&Z=b0xdSTHwmENepxmSXf^`}h0fHn^yzUb@6IyLl3h+`baiVad#iEM4uYfj zUP|Uo#PR6U({jp6j=dh+NSS^Za$o?7b3~c}mAW+Xzba{Q$2OYbN3CcndfWcaS2g{k zX*1zF$6LOatJC3^4o2X%sorVsQm$bAokCk~=R=(%>vR6S-WEpN!o}@rjcEI6?Vw>9 zL0{`jX(=8BtzZ9W>>w+@*y+FuA2`xaaZZS!CWC`tgw`G11eUocibH9PWrd#CO?1MB z$Id2+I-J?WHnf2A^-T=+Q(7$;6E^g2y;R)Aro2enrMHU_2&yfk=3lSud}@;$MpKVC zsAR>!+OA??+kSsUF-_-IdFi@B5rmKV9~qR-7}(nn*;S<2Y2??JR-D;Y=*x~lRR-rK z7c&~nWJBV-_b+a~Jk-pUu7lsSo4{Hxuhu0uB{!uu7c%#cGdEvEEm0>_!5T7JE*jkZ z`!9W0wW^5s{UlRJI@&VBrLhwJk%<)p2&3a?2*kBQfrOpKyW6Na`z%=IxzAMh-*rZG z_3f|9$Nzxa0zL3H^Y?j07(|c!dDFGIqGBOZirP-^_V;5SSpmY2)I;$kzMa_`^?Mqr zoUBe*tq4z4sPjl31@!*KYb&ApBj5f6VS`zJK^px0*()MjP$hlK$`2nee$NOmb9Y0{ z39CG@4!dWL_Q_0vi;{mSE0k-Ek42w)-FE7d@z=Go(s`%z*&^QSgSo!OI-7C`(+^%s zV}=bLj|@w7ULo6v?H6Mdo)3TYFMH%Y`|HQp^KgaUsRy~JtI@n;<+I;c>cx8gg!-h% z{yA24IsQ1D!WiAr=$O?h*Tu0=4o^FEQBOXJ&49P?{o#*zD{3l*o&%=kqHdwYct?kG z%hkkJhvE&JNHAkMiA={w6+=?LTJa}tKET!HXmc>}qreE~xqu&|>!}|M(7?sAq~Pao zS8bLY038b$*xDef(Zl44|2;c$DMlBUPOXD*d+1)0J_l1miY)i{XUVhEG?SGQnce7*eX))rvC~i1i*y5Mm(ol*W898y&6BD- z_*_RHth5p)1`#>jwo6y|Tn`RSD<1cK?_O*8%xo^?6JzsbiCn;v*r{?`VM2B3kq7pi z+&ZVm%$~T!=6SB(Ra&M}=FsZdYfR*RQ(6~(tTw!<%JBg(q=I>nbS0HG1QK*WBB1w) zhjjXh$x&zQ(rZ+Yte?ihy6NM2kj}9FPmtS~+n@;X?rduKgj9Z&43lrsh-F}FwEAM6 zep|}Pm6fo0Xsnq9>X+!_k*?a*>L;COu_af&ew-ZAT0LP1i}bZ%5)^$M9Qtao72!rx zMm1!%IIc0Nr(7d9^uI~$t+L2p^9D0ZhSwbP2P{HH6)r*Th(PW~2CqRg}MhCQwSRCvC-q zYojMkK(D!xtWb;ktE2H?^QiJuwzN<|tCt9ar`#x+xgM?ZQ+{bxgDxRPZlMs~^{epJ zw9^TZx)VO~CTEu8C(_jRs!A0waP>*c?6v4At|XT*Lk!))Ee~{Avw0|7i@(mf`4f+?u;NYh6CTM`0SdCT7kybH^L z$`-2D4_2zX4G6@1Qy!JQU27hG+Q0oJG?Z*;6W?5D1r1?%kNp@k1@#_0ba?R1P%GhA zilG&MVy*6Le(@Pm0T?YsvaYq+t83nzEYtU%j_ebDRvP2g^WA#b zq3VNwj{mc7?ZYyJW+hQP4PS}&SfkX;it978J2S^7rC!b@x85O_I{<3WY_7cL9{c z6Y6S}@}x5RqPiUJ;hX4_?tchR3fB=SW_Ioujfrwp>?9~wFskB-Uh>cIdR?|9?zeg~ zhSoJ(A^wrXR&Lw(s<7?#Csg2dS<0s(4{K-F6xx^N#&7Q-5!gC%b# z{ktZKwo#>%h@>FWbMuMof03bV#eeK`0n=^Ig3QK}Ha%R1|`etkq4-j350cu7Wt31C(d>#^)U=rXM4a`>}nHH)Hsb`C-v| z+)ux$8ey^MjQ7ATM?L;T#tZ?Ld2eC7tXGtL3|^1xP-jG${bme~PQ3iO#0=&W>Q!dU z93T!gx(U6rTc6K=o>}`!q+%<$Mvq31?SHE~64x1cp2WT%E#sEfx6IPAq583V=cA+n ziS^kWa#wFLnPJ%Fd=!@X62GY??KS$v$IV7FII5gE0r?tZRL_X(;%o?Z+v+@d66z?pZu;d zls5sjU z$74m_!1+8)xapD6Jib&jlv%=4A7r^vJfL^z4Roqx|2$C2?I32&Xt}H-$h~v&&)mtr zWh(6>_lEOlQtjmRMAnR$K~+*YibCHWH&NEimn;jMBp)|Mi=MgyyK20ZW~s75ANY5e z>r2qTP(e@bN_~ESH%{(uC4%31y(L*iFgx^MY#v<8{$gBvf21Irge`s`e93EnT_5e` z;eQoPHUE*4S6?k#ansKEal0eV@w5qpXWE(aH;Jzt+k$8wEX}y(eZEX}R%<`)6aaK3 zVRWRsmqBKA_7~D;^~FMfoShAEY!D*IE7P4ZuPca>NWstq%h!*5lY|n?4qDX+QTId{ zpR#>Bf;5!R4DOdI9bkI*pBW169gi@$aCG(nmCS+7iCR;;Nf$1X?w3i+Lge2T`o~&J zrJZ$oY66zMDkWcEJ2Ci2=MG*p3$546x_=tt@Iorf9CqV=1J+Yy-zHr2vUKtpUrzdU z@>R_`{O<04LP;ry-RU#-O2(F+!HoBg&ioDKYBvb4?och#iRkbR#Wer<^KoHe!Ntw( zmxC~?&)H#D)j&K1J4hbK-!e#K9^X}*;g(}VaE*XXX7(99|Fu#Pyp=0mnDOq$(LXkG zQCP7)EDS)ne;2{vC9`aFx8D9%WowPRyD>u${IBdDOXME>D>F0Jr;D<8Np`>fYPm}h zmZkoQ>h6uzKHKAk9r^nIs%dI}U*C42)2%bX{%o!Ftpt?fF50{F zpP(R2ZN6)NO(7H&FIHEX=cBURRt z(X1f|R`ruMR1Q0Y><`_tZk+IU56=t_Szo>w`??tR5^cFZxil>IH#~=Wv0b?Ermb() z;A;(HX|>iB)%fhRD(Q68g~Mlq$vLs0t%!@mV zP}_PpJn+}BWR|Nxor?dG`B@N=*~*|*%V5-l_SO!zq>d*UE0D87?KI48OrVDEmEElM zClQ7VOLcc~?(n={R06t>>q2DB=M(qgrG5t(T~c}J0lAggbC9> zN+9j*7nxM7aX!0C)&^lg>P@AzD&UnG25(ib+X^jMj_h!MZ7KRaQyasb%loQr3D}ho zFjQjrn`+;Wxn#UmT1iz-N^;rp)o%KD_=9z#Rg+eZAs^n2N#v%kbnXI9Hw69jw`C}S zZ-}bK^&z{Uvs7|o>(?)lLFC9^hbtAC@1#UTrY?AI+V0P+M`I?%PtJEmokowK%1?dub^y z#i6(c3BesgTPW@xECq@cD^h|}+}$-ma0~8)P2YWY=gRJ#yEAuZZvIPhCUfS@cTUdl z`8?0_+Z?{fovW>=Ai&!>v$7BGbEA}upd(ezS0i>Tj$x_#_6qQVFis}NlQ&?B zE|I1oD^6R}^r%F-o0O^7^t!=wucbfnwG^I|ZRou{GfL6=MyP7|kQSB#)m>s-9*^_W zVv?fOuUgUGqp6R&iliqat#pDb30EJsfxFIPm09^z zI&EttGhaidVb35-CGt#CAyV*zMTwFnB88P2IzrPu^Zlt`EfW!sxy>!&g^Q4t{x($C zKqa|x^$5KROMVW%z3*xfT>3QT4+Oy`Barj)cdU~D!3bv9tKB7VHBEhH58%_i^m^Rh zbv-stU}l0uk@>r!3Mm`>rh2u_35%lX+TY1s>F1Wd`8ttCjq!BB6U?C2ZnrdFw(ZZr z1#<^zO{6d+MRlB%=fpLDGulxNNg{Oc+rp(E>gnt0$&+KmRg>q~8}z|-+hm*bL=qM0 zof037lO9<*Y!mLON31uyr*3QI_1(>-8yy z8-PSz+#t-q7kUS#_f&X4MNJ?Yrth`b8>Ey5e45!s4Kdkd17wDVGFnH`zh25LnO$&3 zuYUj}bNrl&_1#=H!BGK!@NNQ{$wpP#{E8MgXl^x%u+M~VaFM|z=^RZR_hQ+4Gb`MTu}fBg<^Ci2*AS2spbo}{f6OdhiNX-PZ?fImts84Z_a=X{RyRvbH6 z>qp-&6(Hs0*JoC~m2Bq~&sNVX%vBz*ixGVHrf?J&k2pE_1JZ(lOs}HFOeNaKu6%pX zr2|AC`yjSWQkEp_o}Zl<3IJ)IX#TxxldE60wse3zTVr-a4lbZZ{{L0pm_ z_`JsX)h^BMc!Ms@vyB1OtV%lWYFEPOA>$tP-KH}x<)Xk8N0zCsulsX$8)csP(&YzQ z7bhJsMO~kk8%nCp(q#gJ<9?oZ`U|fKyg$8J&X4oEnor+6vGB60KXZ61J=~b+Fb$om zBj9#C3S$%{!y+^wtzfRCrQZ;=O3E@lglu#iEtQW!fLeeB$D>>@@Iq4)_ z(3$-^{RhJ{Ic1r(Z{bFADU}klFWe(Xe@7hmhKqHS-Q(V4k6Y6R+Ai0QT3akR=D%5GeV`Pv*R5mm1%d{Uz@YUJ_oE6UeUdG~eeCe!*<&-6eZl8q;;uKN&(| zP!6)%>vn(gM(gvccKRrwSKpi{2KNIL&>1V(NTc@zj=mj^`aHJhlA>j#^|ABs{jI6t zaMb-NxVq$>dW&BoNIaG)LGmU1ZnuK4GBz2EE_462r&Bv%tq?j zFUmTn5RqjY!+>47DF+FjX2_x^3f7oJ0u)|7Ni#`u9Q!u17^M||#t&HvT*UrNT zv96V>Da&bWZcv%U$CkT9x>J#dMF}`5q<-_to84CZsW%E{w2MZ8TMeV+s+_fqYd*xB zMH*%(gfL4x-k(U{qYbxx!}JBxP$}nFVtmw#6Zb|Fv@G%gXVvBSdACJ^Npvqs3zpIL z7`GIgN^erk=3X{K$0cd5Y4}aVpgyaiFmVf=WLk$7H9LTShGnUN^_vFM=!<-Q>wZ&CyWw+^_ayUd|udnvNn|tU%ANxA;6hs+YqDm z!wy<&!(G)9C$Eq%Cpy1?cGxa-v@-o!>-w zCJb5D-Ro-WLlkv2v_3Kke!;{I#HVx57}94qmFORcraM{uP45P&NS(d=`JBgro+^h( zaA-=pbgPMvfkT$lay_1h-xL0JIyrEb5F#VSq-(;ve;Tx9LNWVOti{r{e}XQBJEse; zD`s&ZK^?xL#%n;`P@v3MyWypCUl|@p0;>9sWb}|gtl`Z6X2MP1eOnc#bmgq*+c>1M zk~;)HNvB!!M9*ygH0z`Eb#GE@-p7edYKKH!1J_B#p1DsXs)AQ6 zrk{fv+B3zlOxsG|(tLvbe2X8^x)}RBN3)<)V$`XnlLN23`J=?1#ga(z%(Z{1b#N`iT^5&qmvjtkw!lKhj&+A9%t-ffSgVGV{l`5 zs*JsUWn!*hO;btB{q#6#BSa-7{3F}T$@|%6^txnPSpBCz9VKax%-p~STjTQQvWFy~ znFrI4_$m0mg52+VvzVxs*6lTUT^WVmXGLa@ifvRJ>S#CL{$zVtSA|gjB`9&t&pa8- z1O%Hu?s@6gE)K3~eve!_V@o~ZxV7`xj1auyS*(@z>+vBibNvp2H}k(<4GVE3k)#@n z7H1(nK{&HdSt#jyEhGrGH)yg4aJrB=_I=M1@ZzTU^90BIz=dUrW+tTmn`_)+6?xS4 z#rNyX_1oIb-Vp34%N?bfOj_dK77vx^e~#?;*I#ZP|L_`bc6*bsDpKZZy-*C6g;5$? z%BN>mBDK3uJh)puvjh-U1xKH`yu0DV!}NIDLWNJa&pE8up~``GEERCu3Q|u$d;cKE z(wLW$8A{kSmF7#9&D#qZRI0_~+MSuW-|UIJY%p+`1BM|dn<7`c_~)J8>f854LMY0H z%}CRcAiRzgWN#!&+sjQy8TY0jpuMQFi2}O6?VnrNWIyvFEwKKZ1~DP!vx6(v#4Apz zviYw4Gy!?M&lrv*Zw`OqYksKeT%-#1?;Hx=#j z0%bkJ3m&jzVKFPK`5EBiw#!@lqjls9vyRDNWr`#^U0@IkjH>t7+V$rOi?%D5k4lOJ z(On`Igp||8f6@W(<7?Ynn^aZf;itZ8c`UHoIv9XFbwGi7nsM_2BEO$ZiD(l5%`7i% zFTec~4X-Nm7RS=z97MkDUhdX$?NR-93`wm6lX?EsUOrF8%V5)ri7$uL#!i%lh4%DY z^(upgLp;Z5GBb!!FeZC8yY%zt{D4Q@Ls^>(M@@U;7I|yLR29y5zX{~j z9Gn-z27Aw*Q`H*KvIQJXP?Q)a9jTB*l%&Jdm^B}T!lj@mYwPWJkks64ryyw_Z8n*7d- z&f*Z;`=_kzx~iGXeYuj}h6ne3YqG{jp$ckcmv4-EJ#pO$L6eyoPJYpZoSLttp7}^b zwyH{g>qR!E;-mm$5zxCX>;_!ql*P$b)1NP-ca$qu52=k=eB(a(8P>8M!20;i?;fuH zOkCYdWahsU439svQ?CRv*a`LxZzWURyEGlOl&=@#oBUcRl6I0#J6_ZzJH!bTW=&|}`~mU?4%X6_O{IU}2J{ zR?ag|jahq2yaJ@T&8(#_NsROIVEE!?nYpfcu6adm{d2MjNM0GF_qM|UmzZr~e3_b1 zu{J5FYL=2#F^CDzRs)itk7}H+TF#n^x~$4x;8`{{X?rQokGn9od@hsk`O@>DNydgF zU~^E%vf+O2QA+h}PaNsXa3#W53gW`{t-0^K#|90Cv&1lKD^}2!ousZow_TBW4YbUP zE0;kFO@8)SdaW#*AJ`E2B56mnRFCP=&IEcrjXflxJ{6QuMI7xd_Up7erY8v~$^T2g zblLPO!InrNiaVV<{eXZUl0}`$Cn6%1QMR)dS*7{FUvTJ$`W!%^Nv#~2C1qd0GFu`@ zOUJ>N4n?9W6RJ+CVkQg_P%pv#ICmB60c)J(01puMp8H(n`zImE1J-9nKbQp}@^QSF z84KzDi-|^IrG6p1AP+Vd1E_A7wH{MZFNfWVR-}aR`q7)OghIpkVDU7k**b zc}^oX<9rX$PkC3nVcUIMYJZv2af0Rf3$q-tYYea4t$E6#OZG;l%JBqL@+~|;Dr`_> z*R%6X?hc3@}&qyX~Y)tq|nzWjW@kV}G9NBewf>ir8%?tjN-Z^eWl%=#bo_*c zsD`Y4=#`@;phGeeSl834fKTg-&Lorig}xVp|3Pr*8j8Ebt#>^H-|epi=f=>kt=$Rv z>n#nu2Tg0<`fRO?SITbrU4B|%kMLIE8OaRyi)_({(D(Sw-D?4!rQA@2g z7wR6)LQG`uzoA%&Q@?Q?tCg0f9HAiR>rD(rYZ77nUjj;IH2>Oh2ishE$kvbsOFpy{?DE z8^PJ!eU^Gs<02{e;(08v#tJ;z)-EZxm*mxU0DJx{)~e~zsYZw`DIjJyClqksCV6U- z94||!(p?D zn>dl|oH)qUbKN5%JLk&3|GvK9<5avP@Y!>>xhs4r^U7nFjnibNeUA&f@)D$aTPf~6 z$it)wKT*`K;rX4R!{c)45^Y@nryK|CXg|w~8-jBC_4-v66U^Dwhu7*?1)%p`^g`tF z^(YfAWkZ}?e!IaiPs1>`4sBD%Hw-0;8GG6lD|p_gjR4fy6lcMp>Cn3-9ymhO7MD*J z+_AR&WYuWwNT>O&WY!Xdv|077AmzpOP&U_%W|a)CtxHPIXhHjxI7(Bt1s?KMEO=SB z5U}&Rm-7*xt3&w&3WmfOsbla-G<*@5X=jm*dx=lF0<;+eDoRP+q~b`&2wXZYIox4 zJl*=YEygoZ?XZ9@Z*>?r|^)4r`laud8 zd;T7VjIqZ~Foy;NpP8^0e0573NtyST9V^+O8CzqMK2smx`gq#P$VdhA7_giP0$O^* zsAoC0iv(S^G{=gvvgxN-;0ENImbNNyYcTf0!XOLme9P$uxU{@sH3H?& zQ>H@tB$!7_wO(`Q4D2pYwc4ReDd}YYq;|` z6M=gJ!S~)ZY+`pKwuyBES)S0|Cb$(a6=ybL8ZdAN{__ET`ObbOA-^zI~1BG z3K#Yg%GUhzq3d@^D_y?wB^J1A!ua>zD%}PzMT}(9r3QKMmqBX=LBV8ntP35PI;||e zwKo?U@%^4;jKQAJ5gD#Kj7DhrBzoEcp(2C4bDIumBL!hx_e|No#Z`@0_ypTfQ|ke% zpeMUr%LMV09qr3+QJgGxo#=8AO47f31W+A$hK4$CoF$fn8byVL$%TZ3GBPr_(+5+t z{MdW$KX@NLWQc64nmX}WMj&bbLXB8D%ebSA zYaf^*S|pH;L@cJjbrj&8gKSY5xBiPnWQ?Dyq@#~Uzux|n*ns{BS{xnoKxoOd*n z;eT;cqn4_wDwq3BfRfj6mc$KQz4Y&=Jxb^d$E^me+?MZOLDqjWgZeMfur^d%17Y8) zj`!i=q5L&VffUp+@~+|Hn5(X#i0XsuddEe3XApV7#nLVM0fx(BFLHmuFMpU&Wh?5* zs`0t%-99h~dI?$Cx7VD5r;9g#U^Tdtkfee|N2rH|cRJ)7WE-xXB71&@vcOybMu2DQ zePNCTzZVvzQr`|sU2%GO#WoxW8CEw}60p+K55K-9=QBttc#Fy(Xd>z_1Lr;6YC>Qd z;vi;)SYz831)Jt}f1dFp?5~eU!wc8?Q5Y10R5dNWELm}P-#j(#e&QRy|M>AQpSWG% zd$2j7P2R}7g+hXJ44k`DYB8|PZU7^B#Tr#GaF%+dmaYF2?vaIC7q+WLZvfKG z1xwD4jk-%5^*>Bkk@8edh?2_gR+1t)XMzpH+(cm^5F>2>I-htWBNfLM+pQvn~n z85^1pTH8!F(Hd-Otm_^@+WYc@agj5<9hI6)y88jH>sm%#)B&e#^=@a)DF@`24d)&E zqSaD2ln1FXcC;8e)7G;jwK8TWiX-Ro9h{eQc}1H4F2Q&pb@a=dhAv*)lgtqIRO9yY zw>}=&`^?VwVX1e(nYi#VPHNct-LU$Ky+{2(zlYb@4+*$k@kPb*i5mK1+lQ81v(j-_1I&=|ej3 zZYa#|`OsFU1mf=KrH7C4=j8m_%00#!EZMFP8tFhiV2F?8Oa1d~v790Pr>-qD>d(|M1y$S9g z6?LL>*sVE~TqsdI>nBE+1P6y4qmK3r8_rf(%blaVRgTB)E?-Fgr2X9D_)u}mrt>=q zS*?&XYZk`)*BVwc%z{yN(%;aC;q-DKyVk!%k68L@2*bv>i;7@)wGrKLIax}j*Nni| z8~){nGOqL=f#QEz^17bR^NgXCxf4%`GLt>th0m+E)!^q{aCL5t_K878Y}NfTVWl58pMMGF9N)D}ph z%yz3qIwon`Zmmwp^%hO7U>=boho;7%t%FGnsot9o=-P1U?QY~J3JB|b4YmqSf%3!) zL7?#J9mtw>xVGV7)^J zzD&9l4vPbpZ{B{M=o#cj7&nR-iA^)Z)-kEc64=3b^<#NvV2!kRcDmu&_i~{}9I~x1 zF38fWKGr%hgQfiV!-|A;hG<1q8$H#Zibrn z=*^zs(vSI%n)?pW?w0=St&|doUP`s2AYf=|mcRelk~%_4GP9f@(fagS0%$N2Clo+4 zq>B^Kjiom{@T2=PjV0)wqgb%S+PLKbXlSQ5D07&*UjoF1kfIud_$@-3tTEUDZmqj( z<3YI65f9aDl^J!C<8ZdoZ~VPC;;B#}u3TMIgfd-4Y?aQ-b8f^ZPxEi>Vz?Qw zuU493u{Vb=Kb;$19nLn)WJW@rGK2YQDt*Uc{2s<~Epf$9g?vYRP(+evs?vFc4SJIT z_r#u3C|i^-;h%Fc_6;DDLAthEnTI1tKl%cD+*Vh=i$kifyBQNY$D04b@lZ$bkIz?q z8lR)PR?Wu+zA3$d{mbFfdh$<3izJ-XstF?uD2-#pT1&;SY8eUba?gKRyOMjux#UQz zC=^qeE`B-cohUsPuy4wGzNL|P71`?0AHVe2TZ&RP+1*CZ_EZY8@WrdX0LJ(EDeRHEjnT3+;#Qm^(N7!1SKV3>(@p==8aC++{@C$~i=U0A^26{Y^>Qr4_VTz#sM!U>yeqq^Xzl2k*S5zCJc z*6nW+mT)agRC)(agh_~S2+HXds_Hc>@6e&vlJH@B=APs%$`8RrZYQLIz=c{OTt}S( zAPUw4^o}4kQDGTI7Rf)qrGcjXLEI%Z052m|iR@)XZ-@QP=7l&0u6o08GemE<_g z0#ke6Pje9vPuO%abdG85Dqo~|%Rl_#sFN-ZcSQ6#U@SbOKJ+W6qI)qI`Aw+MfDY7+iFHv<*EF`*i2e~)ylkHBEb;tf@X%##CdZU z*=7$tl`V6;o8Bp39Xt~P9$rTXo0g9Ra5Wlmxvse2hICKlF9(IRM8`Bouyqaspx9PJ zidW~*lc~$I` zf}rUVX}bs1Ud5&l$HZr9*f>xhmR8U6`3zx6wYv1W#^MS7Iznmv=>SCrw+Y=;_bD3N0>8e zE^*^3Z!m%knM6{CWb9xQ1wlsj$}@xS{fK^@uOt!~ zPIl0btp3QlUdysyHv@PTt|@+o+2^L$Qv>><<)IenD#N;>L-c9---LW7_q6|O*6{g% zWes!(7ry=0Ft=Lbq0$ab_nh`85zY(|=}eF_gz(Mo!>g>ou8Ipq^mDBu`s_@Q{ z%Ha#F?`v~)IzKnr@BO+vWx@-RVgF7MYVW(d{u7GO?^G`NKT-tA*tOIa`aHx9k=vf@ zf~Ui(?4Rxz?gGx2b)PiRX341DR>Ba*@x1?xBD8x%QQ2=VExUSH6)80JeJe6yMQ_y_*hZK9PYX6-S6^{65=bS;}-+ zpsVy4*bUL$dj(eULS`rN-4GD2ClR2#(x3ew=z^EFsdx%LHT?=+yeum=xaqS!eoRQy zNC8jVtoY&kV8gP3wWO16&3iK^Z0@}CF0NfX{DYQwZWn4-FDW^WrOWji#;Rt9PTw-~ zn`oN7+WV>!rB{Y0)fT8J0YCLsZ<9rcpO}>6)usfe8kgg%6evM-_qj9cHOhQm8=v|K za{`pWHesgoKV$~m4U$8cMS45<=~I{$;C8wf#d;Yp6q1P1R@zYEb55gysJ=jmL?t^N zf1xbo)|TgPyEu`U?+LsAa95TU4hhI=9r*4_0M|+Q)0Vzo zyyEX67}&hnw=MOx&TW~cnez8DrK_~ZKOVd0?=(`85490$&iXS2vHRxptlp$2+8G84 zCD`n5{evH;fBJnadHgx3vw(ThRkqS$Fi={?f?C4o6gAAlDMxO4lmkNCW@L#ZB4Z30*2b*6z)jSyXP!k{ z)`}kf1ryue3>LDy)&ufgkreXKxu*#T`yajx5=GF4f^c}9QO=HBC`7(328{XXfi@2 z>JkYtWRDOiB||*PK`!>1BZI!Z#-ypQC;J!!6ymYY_A$rvlW>uGGZNo-V~9A3^<0K6 zQne*ptP+P;H zG=dla_e|ogWGrt|xCyAO^qo}x z8C6>>1gak3^rGB5d81vy-zK#acj}|ZAZ6c{(AapP`49_bPyC60S9HVRpK{+3Q$yk8 zYLOn@W&%FRa*u!x0g2yOD)hB#0ixQpa+ZA=i4FpBU)=eaM>WH(;d3fMSJgkPhq{M2 zBgh2oym+iOK6xy#JrMp_JILY%LVjph7jhmnj(y#pKb9BD&Nw>gm2oyk!P#NBt}*QM zMy>JBn&My>Ho5L(*xq2XHLxcI7yV57rQ*O#_a=j_>5+oN*Gquj{RhQn^et+RC&7%k zGH!j*#qvHNCW5eY-r0S>LLKO%nu*}gU zeWvB`zU7tb>E0ElgY_lPeFCAMi~n;51Fa1lg!|Gdhg^5>Vo~B;WSs_qEdO1+SrgUK zq67Ar^+kalT*2?tOqiS_ok7o%8?`3#)cWn_wX#2l6kbDh-d=WHE#uj0^nHVZ_k11{cbIXQ8IKp$6aQ7_z1NMcH~iNbkk(ZY z^nMPyM*k*vlaYnY-nt;p*m01|2~VqLBXe^ZbjywXwf`xT6QSR9j*b#m&EAg5!c7#s z0)CyK50sOW0}4_Vs6m-UMX$BBxc;i;$73>ba<12>w&-u7qoZRi;beoBItN72@bI z(HecKGV=`q@_}G_6#8g%(^P19IK8N7+U4b?ZpGi~PBe*13=G5=8XEc`ANL3V*bK7e zGu>oB{$a@7Mb|XV00m2Qb{YErj*`q?YoHUTLJy5jgoTA0C)oH7!`sCUJ5I^JdHg+t z3P23@dCJvK`H+;Fb*ZDvkkJPC-;l~0d|7*KPM3Cx?xW2=GAiDZ<+pUUe=!dFtY!au iV=DjK+;rSOSS5669ma#8D{>w@Q23xKQ})jE>%Rc79V9gX literal 0 HcmV?d00001 diff --git a/documentation/documentation/static/img/project_upload_form_3.png b/documentation/documentation/static/img/project_upload_form_3.png new file mode 100644 index 0000000000000000000000000000000000000000..71b525a315cf92ffd1df5ea43159927703919557 GIT binary patch literal 3966 zcmV-^4}tKBP)ZgXgFbngSdJ^%m!D|AIzbVG7w zVRUJ4ZXi@?ZDjydXmubmH6TH7av(A=GBhADHaaykIy5&RLq$_m#;kI<000izNkl7Ops=uz!oorTWHK2tnG6pP4?H|P9(fyU;1zM;n4Z@4CN?u`}&hV`yAiIIO_;2t4NQOGL?@|rAK1ihL+skuA-a; zOnPw(C(U2s$f;#`b>L&==A{#S^^*zSqdc({wWlTxe{LCz%Q9E?7Hno@r~750nxfMO z`M%B*e=iw#yOR$SrBX?Db~dG@r7d1(ZEa0ZP|zbGJbDq~#H3x~_p*Bv8-UBI8e-Eg zabiK=Hk1-!6A*>JSvFZ2$5=bUk0z3%{(~>}9H;KFc*5Kyhzi^p6Ds##XHLfKVVk3atgI{)3IzaCsT5ydUtC;VkjZ2K6c!dzP*6Z_ZZ4&zrKF~&;_K^6 zP*BjrH2bdTh2XSDY_!}u8_aKcBtS~QeG*hYrrU;?B{rIPjF2Y*Q+{VYVs(j_%x{NG>9dSUK<*V zO>l59a=HADYqUcuxm=Dyp&%U>@OB{jdXHzAV2y}Mx9*$P>|VNBm} z9F+z@&2K5gFzq#$qm6a&+F7>0I2>1NQ%o$oGbm~W2Y;{CZJwrtZ#T}wua7-urdD(v z5J1fK(^Tjh1XpJ>)$VbYoxQ<#i##xLi{p4LpsD0cY7FE1cfs7)1gWz(5pQNuP~A|F z%*hsXe|bNrKYEFgy}L6xy}a3KqnUTYd zqHT;dwjg>>b-hheQ$jzfY*fol_C0HZY4FFCY3sdml;j!y40M)aCUs%xvl}?Bx=pF? z=8u}A%dX_95;;<=26X{RatlIsRB+>8Ph)O8j`xZ+z;(`j z_9}s1F4&u!Vcp#m|5>U0cun`Mc~B^o$_BBCjg4(qY;H#`mlGQs3xGnQK&ezdP#fFB zBP%$OUQ#mu>B8L6_U)?2d~_6nwttY7ag+wh(Ufpt_kU9}Fr3g{&BX|H{J189iAlfU z{>nzu_k7IKv2|q6jb!2B>pFg^hSP~tiT`;J^R|A*2TKBw9Ev9-XelSFn!CQ5Gl{{B zjXR7*R3e{de88fS*V#356cO+IOkHzDjK;9>-v^o7*O&ndj&LzA9xnqmzokYq`niuN z8S)zMW$fbZ=Uq6vE&~6UoWBJUK*Kdf0?%)$A#nLeEE!{dXZ`AnygxmJH;+3oZS79> z>{`oY%cCrgdYN5UBn*5bmmk*qVQd%8&Wjh=H177im@d~SSsyl@d70%5irK`Dt@C-} z*Of#sJI2j6`{O2G$3IVoT@0UotzhrE0P6NFWbzvaxh{6*h3sEQjWEY}{AMm(+)1>Z zh^pK-2%eTonePI&WoGhz+#rfmXEJ&9_q6ahcpzkDWdZe(jdu9dn7Ft&;^N}&I4+aP z8X}wQ?Cb|?WIND{Ayr$6^pcV?(FuTV(sm{aI{Oh7WY5Rh87S6|Vze=6R0rAf%}u-( zh0?6vXv#RLIE%(_8|!C>V+AmxCq_o^Q>3|yrrDIQhU+%bWM!>ogegEU0ljPJAG(>8 z{qxun*0dcORTgip`5w20$H`dai6KD1XnIxugTPfO)ckRchQljs`-K3 zZ*TDUo2!YNuZL=+h;JYP-9?ggS~|jBC!cldpj&U0oBb{)tKvtlUV<)InQJq zU`vP;5fD1wjhmihdAFtw{tyAEx#2R4o!jT)VE_z!jHS65gr7f0v3e{$r1n@DixAaW zVQ+7RF?IaUv144c3}M6SnT#_6{D;y-V#zM5sixyPNq&AlDwPVURNAV@=62+Ac|%S| zsZ{D^68Vvfa2kuvu6z}9(l58OMpK4Q3Z+X?HW|4$04P7ao3E<82oLSmd=5lpiMyvW zbzd%K%IjOmI$l7Trax0wCo#|8p60u&gl@qT8EV>436^7~6Yf+={_*p7JV|T5JIs+v zS4NL;prWLNl9CciN^0=*8;V8o_nf-ay2OzB_dJgMZzo1X1l5bp-?q4hMeulCKF7Yh zr3eP@!|`=!At`R)#sGH-C)Yj4oVPQ`J%5GT9$_RTB@pIvuk$^%{B-m`>&{XjC?!(;h4ky$_mwOEGS+bAx*Po;;?KQ%mbj8lrh2b+c@@U0g)j&9kiXb->ovw&5d-`h=3Y3d$;4DL7>+%8}w|-*k8*c9h{L zsYa!`hSp-Sy(9K5{UaoOh}o6R$^Z!mmOR67cL!{``SA40L$s8NY8egXW#xz*WH>a@ zED27|P7Q+7dVh$K+FG~ik8L(SiJa7za4Xox%YoiF+t|@}Yz(PqD(T2IBic1KE-o(j zT!*%-wk~}`uLLL3OUlXD`x5CTZT8%Q5E}rDo+Rq2?i6Qdko(s+>^^u6ukcWMw&WdS zS3=@c`NH$%r zL!;3&{nVUg>A=2T*1j!t^yD{ z4Q8pCu^?Icg+#SpL7FEOk;|29GHKD_pI6-dnJ_V_rSTmBYPdslH@ z`D^ouY($7eYRXE>ZndL4mqRDC0%(BS5Fcy`GueKi>{di^^E5lteni{M`{j>2xxxO~ z18|RgkKfeL$)*p!k@MIPHxk3&|IPXGhrTZM{}pB4=9(fBi7FZXgXCTcACtBXOjt>Gl6ISoI~Ujj$A#lm4szSk*!$8 zW4cG4x7j*~mtGsghcCQH(92hd3+_qv>5o~uEDy)2yNPyg{WC|>;~%(Gy~^g8nb<9S z8gGYMe%ZH<_kVUEaE&JhfLJ0%bG3le`Df|l-XCZ4UU;~P_$+A-DZS<+@BAzKR?lb0 zA0Qf7U$2Q@408tWWWn_Dm@J;l1m~;dZCS>LW%ih~#UHH|VX=g|OJ~UY!H0n!J@D{w zmLN+dH96_|!4lan!x8|Svf5E&T7yiY0^os>gH ztOu>t8xYguzdq&Qwx_Z9W+_vG!+2rSPxOmT=iu7`w5dNC4`cbi-X+@ZBx_~_Gd5%f zE035H{o#I60xS_B!crbVP}h9k^z&zVUJXe4G5dq9Jm-9dw<3q)U$|CKEVf9{bBgbf577X^Wmj z_1S!)#s@HI{&ornEhalXmb>m^xsPZ?LYq9k7qn&dRdDoTFO}@Ivv(Yu=p`k&jbc-{ z+Vj5KCU757SD`AYrn8+Tdb#W2A(Nkg7X=%f1qxB=%d(x9!EI$f)L>d zJnATwN>Wo(f%>fttsNQ_78a76oD871(GBQvgd-sEpNNNtN5d-PL4;OCSVyf(8o^EWzD1xH}gK?(P;W1b5eqyGw8nE*HN@AKrJm z*Ys~zuU_BknKktXmwTvFr)r;FyLLU#Q-pq%mqdF*^ach722EN@ToDEa9u@`$HWCRD zxFUVwoeg}vauk(TMgoc#l5q&|ozO`_-AT#T)XCMr!34(4#@5<|$r#LfOLF1V-81nSz~*Ld4F2f}NF}lY)(#kBf(ojfX-^LP>eh z4HX9lh5|-fTtwL|<7maz?X3#9Z)5GK@aB~$-KW1a>VGNZ-b%!VP=&k|38Uctp{Dy- zxm6P4?;C<3PXE(iz0==64BG-RhMDQpdsw*rK$;f*HS$lW^fVMSY|!QT#PD}!*GG#r zV27+!M^n^kPC4O8H#uExlbB1+%93|;bIaX< zg@v{HQ(HXX5k)R6-{Nxoh6w{1*8d3k_rM2Q`y`d@B2%G&7M$+IS$)Z z0bRF^K+)k~4DzvQ1z{ayK@QUq{h_lIk1q+W@3EU|d}9^BLak4fm^=K5uPnv$wbJVnvlh#>R?u$eR)M1#eLB^QR{yywz;5 z$yCB4CDoZ4#ljlIA|gtIxINxq-QM4C9vr0A=&J*sBJlnyJ#a@ifsRTs=`U1TTzsfR zQIDXssh&zvb)WysKi`FM{&94wcO}a6uZ&GhVpCE=!iU#x7*WvCnfG@?LbgM~!-w6i zY8HWr(K$LQX9>)zd~Nz!e#Gn|zmAXu+@_9bLn9-Zs&_BG9bE_?`ZAUNqnTu~iJc4d zeb~L#Zlhb#Dl~+*WqD09o=$ zUcHOY#)k8&t)Pjlj*-gE4K=?nTb95E=ZY~{(y3j@;Q2Z_n~yfF ztk*_SPYC;N~XsbW5*|a&P3(!mV2SZ zw2tvh65R5SVXw8K3<{VMVzZw+8!fLE!$U$ImJ-G>W5AsU2SG9?5KDCE##(9ZE{G1>Go9X-Gl85-{#nAh+ zFC2{OIy&}E=0yaJEq{Mx0MR>{swSDwzx?Av#e$T++Sz~$g?tbFWk{fRtz@c3dLNQtG);qDixERR% z>MSO=*wS2W#%sF*+$|xrt3C$II>My#ZJ>=2#Qt-Zl*=*GDKGwuhq#+Y;Th7S{=1Vw zPq&A|ij;ag0v@41z6>{WLwe*%Lhx&cQ1;#XDVyN!C!w&&NT&bG59GZ&C(o?=s4E1d z8j16t@7S_!hCW<;yiPEl8pSqWeph+A+8xC~%RK67XGVHc(^0FsktD=}WmQ!JnkQqg zn6lK9Cy&dNb?8ex6rWC#PHKzret$8pz2W_`3NUZaP{MQ;JP9r@4Z(wb5^#mBdR=JkS-o+J3Ji&V8@n>Z! z_}eoy*`8lbBXkUdS;bkvFe{Da4`zwD80?~0{9KBfVs#>eo;*|~V_&g6-=E%9#a zWH-nXY`&ZW8(Cr7Zw=pnKBmBy(KN#o>mE@D(KS`h)-w0>=0zjsEVLLN6GtWMkJy1q zwH$1Hi*F3PnSNhOW}T%YRyFt5h|XXMTNl*I(oox$RY^NPQ7H2YBO<$Z$eTW44wDbs zgDB*4M|rY86=s?K!`{24eED%*X5{&imC&7sc~_`A6qHv|CZA!ja~ZKZcW@z%Jj=#gmcJn&HtN#5M>g^qLL z{gX%n2ixHiBDk;*9ZTAGil+` z_N(EAmM714T{gey!WEgeRv9$*8&E6OsWFQx=d88Pa?^Dut}5ph zr=i7hX3d>(;}b^peU#`YcbREd{IUJolVAxtuEX{EPupB{cs-Adq}8I^^QK_8`^5gH zlAG|-p*MSe`!#MyL^q%XNeS-tpWY$X#2nda(UWF;!+G52&}c`3sm?e1#3>g&;;PyH z>(_aykm1kVWjf&IQR2JvV*}0`SG%Krd_u@cl$T`RF&)*Nff8*UHSO7=EvR~DoXJY# zndvm*yBGp>qVO6N14r#0-j;b|0ChMWySEtC=d(zGM+=&Co^pe$wDtDrn z7h>#IU4I`fZbscFEQ_u~g z$;JuKc+o+FEqdhOjU~UL!sq04vDPtN#p$v)){^ojqT**JAc}HIou(EYgos*eFCi$u zybhEA(yrCR!K2pZkK&KwS|!xulh7E$HbStLdH?az82jdt5l>>N^2g=gs4H2X(x~I3 z!FjGtLy@~#_wg%YvxTO0F`rQ$!J*EQMVwaXXOO1qO<$OwWbfx?zH7n4RW+QFM*yL(a2geEvwvha*c*hjBO~mhRS;uPl&-YHG5cGHtHL!yz9c1sxM_MUw zWSfO>^;hL}7PQF}l>vf4#Sg_B(_gfY&nd^b{ag3MiQ)o=L2Y>RW95$IR1n1BM^keR zR#;5Bk;89{{$x56+*-p2n`f%GngL-HOn;RnSYvRW|E1X?$ZCEa9^N zBvlSkqA)ZdOUmK)Q0t`NLQ!WX)rrPdMc?&U~UWj$u^On^t2`5a~7?%XM+Rl;{!MP zW}YjiH&xeAU7c>rhg`Eo zr8sGhuFb<>h1IGiGwTE8riFoJ`TO(7hq@b>V07}0fmpGlwf3yFxf8HYSIpGsOFd{+ zs_FrcjZ`}u%era@+m-gZTa}hgT%!vWe9*XQgBpJyW6IdTZhOy<0*7(Ol9aQF=U;dG z5GSONEvVV)?jjl&LDj9~ljlmJFpnKDC9q}9N!L(pX-tLtp5O^nl0Kzzm#LgBzGCVmjM3o;w(AY)?xZmCRf8oIn@pT^b!NePwukWcut! zfKD#hy#epJK+=>ZJT1#?$=3O9g)Zmz=(P(@rnOj`~U$AF7#^Q zqsKaCDy{nO;ID#uNM}7YodI<&^5yB^l8V-GSAO|y{%#xnMmMD1)0gcfwcA3RNJ7EMd6DggI=;_p2kr&N@&D`7KS zWNMn|Z{(;M!G~(&@m`*KvzS;5|9KT%&h*4Dnn}n}(^fj|Kg#fz<_0LJ*LH+a79>ZM z6%}PW<&Vf^?jEAURldKY7TgNUKpuGX&VPX)c2rW4U<-x>-dbm-lrc_$*YWtU0>hSUfw}1K4WHPyN6Gi-22=!(UzPTlCXCKmbFvt?~;qU{PCJ1zAeLv0og1BO|OJFUo$@GIiV3nOUE= z^I=>#QZcNci@BMPjBGc$FYf2I2|1bd=hJ%3?Uw6;hJI{*G9ZsRzh0ap5od}H{u z4^tPvpbx5zq-HS97x0fL_>LUI4fxT zdW|z-%3RvA!y#AiQ4Hgp&jlOEME#fGwho@)bXY4GXL^Kxq~R+A=PMK~-phGi_FvHo ztMx}Ky+ZuGPs$^;%8Iby75M1ZKr(}flqX@ADugkTO+ zG@KW*!F$1kqMFscF&n!l1oegf-d$N0d6HR7V74r4o^fE~b=bs|tow-_HW-9pRV^?~1N^i{>-{d9u{TkscC zB07b_jm|Gp0Uq00_*S%)^xyTeO4`+n7pritbSRiEP?E^UIjTA1H6DATGa7%$^s4`! z%TGf%Y5{Er|9vPCMms(Dp~}C+2Zj*w)uDelkq$C!ITGqgPs-HXG+9_^ccUbph9sr; zJacB-MlDpms?VFQb3foxQs;VhMBhHFaN_p4Wh1WX;wq)KhUmpS!9_KyZ<5d^?1$Cn}Bv)1H{N)?*YopPgGde2g~Z zA&agOyY+g0LOnTC$KZXPVTAs!rU;*How>fOmXmzy%m4;mv+Y}x3|2vNcDwgH_q(=Y z_vE%`6H4K?5OG9w3V!-D65#620gM3L>eD#^9F=+?+-_lA;9 zKX@j!4Z(}8no8Cq?-H_)CB%vp7SOszM&zWui*$|;I`3IVBN1LZiy%;y1BKAx1Bsq9 z>CKJpH&GrGM@p;C04MYxO2gR(3mn-sOi#;Mr&1=Tt}Gmzfr&JjQhQx(;ceM&b>!Xf z^PZa_cL{W4UXiCawAm>0F4*-foBOfxp+l8lccNZ5=2hePWCAOLcAHF~P?C)~7 zx7r;J#`d(+E+-Drh;whs2)G_e$=JvfevmM#@XjKD{c2TdW?2PydhhHAtE*W>)Gsjagbs5M3@-#l_eU zQ*j|TIu^Z-(&4OJ+e(*N8bqfIk*|qijRo23;)b;7 z+Qs&VD6Jpa8J|DRy~|i?Hn|waSFpdqA73Z_E9PQ|ZAL;%u=)eTJHX7g?CbV7JDCD9 z0jD)dt6Ot&y{MC+-#mv(!z|(PuQBWe5WYK+em*s-(Wre?SS4bQ5d>%L&9P>90-_xUSGmLHM%?WQ$}~7W;&r6OhvaV89Zib?}G1DFnLZXI9O?AcYlo` zz!#mF9hJAY$D&x}K=FABS-!wzt1}$&QP-$@MI{V$*`=BNnzo~RtZPf1=t?Ns35v8f zlz3Lx>-gZr=Q8cTc7?&V^K8@l!&?)8Plnu)SgXtm9lK<4z+o-6tcjM~xuc`5Q_oLi zoM^3=esOiG!mGM(2U@0gE3HQ5(T~3L6@3x3X?$i2LhVSN!$qIsJwdW975H=+6_6pU zp?}-3D~ysf@%PbH<6;=`DMG%F|B{x>Nt`zn2ld}2=2yXl9(#d27Q+U}yM6d{9KW|@SJOH&(4g{h zJEXFYXV;gFptoW+Xrm4CdF!;dQ`gUU>>YEQrat#L=|5d-4xJE0yHD4vo~Yf{>pd2z zHYT1&%^;IHM?&`W4Xe#zsKRN&+}XC8KHX4q?@^ZYYljCnq7&SMsoz&j%luvhSO!n; zync_1+U@OW)RqcDR-aRB)oeF92XRtXyEt2ONgHm+yWksU+O|<}n|_G1-jyIa-Pz$~ z`*b8_1}oT2o#lGC5%aBn40#Q8&FPzMr>_SRu~kO> zy9rF%jg|T7N)FX=p)yka8wZlT6ct_pcF;L#r^74e;Bxmg-0&bhJ3{A=bcb|(^&T-S z<4&ei&5O%8Ip7WlldHpdmc8@s!FT`z2Owg!@LvCK8SWmyV*LzfvMKwiiR^aIFj}K? zVrfmk2*k1qy>hl`UjOP$yOjLgBhr%ir%X#p2li9G^8bwXX-cd4mpa?*mGA1NoXe!L zo`}tnNKt@Iskbh5eW3cNd3#5|{xe9#8$9%x4+mgvVjZN7M&pdjMmwc(76p$i@IX9% zuxoMyD(3oEwnOVYx_j-InTRhO5Iyp@Z{NfvB^{3z*rG`J5kJ*^pZ%p;Mr2@M@FOrV z(E+6bJR}q3v%WN%@O`cq3_^3xBH_|s-f$f~O+R4=5oG;=F*D0Y9fZTD8H&qS( zby9e@__A4ZcR5<9 zb{XUV+A1IOg~ZxXIDrAiBLclXW@+{|Hl_huLdfxkUIPV5oD zqx}{EkBxTwnp%k}(;jLkFt~CXY8fd2Utb0Yq>TQ_$;pBRY%F^hfC}(=e!O{s@I$nv z0Ta{E&|ne}sF!lUxYyaPWzCkT;gFM$0~UX}LJ1u1N&Sayi!IrI@Y_Wv<$K#anlBxn z!fBzHyR($_<3??@>H(96uZZE6ia834K;%=yInFo$R*1!^OY}gxl z62$*s=)0ZOMBXsHj2h%;yniV_#gk0(#^-p3Z&{%7e_6qSj?O!Ywmn-smydv@(atJ5 z1l}jaLSF$;c>6zmgZ8bran0hF7kb0tU7h))KQ4nRCONr+vGMl1H3kZHcJ@6X_#Mhu z1cer9?;d~t-{;nEm69YU93UHeOH3^D=@X2Njg2}Z9xC|#F>PqbfH3e*(09i&Q9A!l z=7kHE?JZOp$!7B5`icY~CWd3??0*8>iE8t0cXzkL7!2^2I(4N0Dr#y+xKF_A9+rTC z$wY2MivN)(-Fl-XFpQD0VV4%#(m=eC>vMUi92@d`t^+3_bu{C~6(V~XsAEBZsJm*T zJ9 zCTn->�ra3Ha=d@|ZKsOl(@WV~vq|%+C1b#%w53>)GF5OxLd(NsyTzMY(=^ttUsE{uE+#e{cN68!MOA z`z0|fYk7t}WwNXdS?GC{G`02WT31(4ae&#jk=4$eTDQbD#+xFEcvWG$kJ9W}cYV09 zZ~A#Quk?deUr0FlseoQM|H_zkcIu#OiNeL?9A|y+60cDew&G}T3%{zVF%FLat46*4 zYwCKPqwM@Hu7ANeA-^0b#P;+f!6bjHS{4tCJIFY;0^# ze?Pn#{0k>tTzB_{0ml++EhB z-bv%GN0MdT8`*=+$niT(67(o?`Rz=us^x4egB^GgWfm(`gFou$CY;nik2u?DL;5wF zx6Ep)PQPMj`+#BwwkR3f`lHuDyoiGlg1wJ5Q8km`;CAa0*6uY5dZ7S4p3L1U*XdBw zZ^7SGP(WB5f{|*%5e83$!i54U!Z0xE@bGTA9j^!X`l{G?i zqopy}9l6~q|6L1!(?8Kfr*7+pJ;|Q(l&D>qF=z-GdsR3lvpuZJr7+(9+{CmX{P@kvEHoHuY!|1rr}zES;bNY-SLjNesP?GyBk z2aj%h#4({%UMq{8hIf3$wc%AF5GiMYKr6D+?5qJ0*5~Kvj5^J=6V~wXFLda89y6G+ zX;2FSujAiW7))|qdG1JhD?5^P8L;1A;Q5vH_xE1AU6z1lD>RdtT8xV-q`ol!aAJ}G)YsQTx^g?z( zm>^`4BcEqR-rBJ-nvo%GLq$LNTR|IbM@g^zN3M#bH&^4W>brcOX(PdwmOCNFa_uP~)Ms zv8-751Yy>d@PjgB=eiJ|IE6n%+vi6ra#kzrZ#*%E=Qfk)Ymr)w%4oWz?CrfXA}6%- zJkPP|vC2Js_5IUcYq`hw*CH23oJl9O-`XTpJV-)JPLkeYm!svxL?6Nhl@OPKA1y#t z)2Pp%CcS}(t;h~UPb3r+VD%$CTBr&DK6UKKeZSBC-my|5qkpw+^5@o6MF9E`a9b;R z0%I378yUi1a;|krDfGaVYH)M61)6-r#94X})aw+JVc~BJOU))p8%`BJ_)9MmzruMK zl1ngSjWyCKSR@rO+=sR4vIzgHRqy?A=djmzuD$MbrUu7*``#bRA;z-zyjQ>iDGi|xobzodjnN|q+Zr+w&t`3w(sD*oK?c!(h`~fuX@|@E zJ}tj}f=a>#*I4p}@`{kz$#OSb&EZ2y6uyAM>gJwA%_sF1svxuZf=Y&~(__YVp@<{h zRI+E@$_Ea@*Gh}}j(fX(?(nM`Hg*1Y=59#f@=q`@uVb~8q)@Mm%$!lsx*va;$(SrM z6TfS}m3-Npl(O5?=IC|!e2m6t)>aQ=f|dM^Cs{*LJJpM)OEgn<6lG7F4kono!SYQ_ z>l$w<{b&hC!p$#TA@^`rv{yh?E*~oej0y)CiW15=lgOToBto~B2k}UGs-c>OC;`Jcq>ci$+9w7qR zEr!aywaVL#=f z1Td9rg9g=#=)I~+dY9t$;WxjW!D$bA;@rI(j+ZPK*G@w&OcmUq`%&(wsUh+;+QYM zUm=>)hrs)RSWsPfd3|`Z==Mm>ZX3u-_qUQAlh*y`FP#g*4EF5&(QxJ;3_5Ee{r9{+ zoZjw@kvy=;H)Fq!PW|0E!>Q4KsdQ56K#OS#tq1abhV?y^1)%)krDS9=%PG2r_4*cn z^{X!FtcTHLmS(rNpxo~wsA{5c6pzZZ{jz%B@!4Sn96!WUH%>KG=KZDRg$XxU$5{t* zE`U12ZHbJujADOi*^1EH8=lVuWDh!1C;<$&Et>|W4(}&__!kN-j#avuP}w_wV6EN| zCa_9(@mY-f5;r`iw+F+(39i(WEIo#FqYO~Nn=C)R#tj`JPTeM(Q zw+~Z@<~wUW$}2#epSkLzdl$G9{N{VeU@mD<3SMgWx8XGw!?L5{vc26IXppLbp_W?x zqt_2Y$>)tpYw#B+VwQRo?Zs@U=mER+L*>UnPGLQp55{8?=gzN@yoG{nAwH_A_-Sij zLp?uqW*Mcvb#cFsTUQwlK4eOLsLWe;F(#K=AJtQ+p`5*l9z#qeq-R_?%n?aXV}YZat-}I1I3%dIB?)A(>H4mUZz+@i&#j}_FCOdh=b5V_P$O}D zfb>JmW%O#;ixLPY4P6;#{7<4=kt8jAa-SUp3v{n@D%RK`;$eu$!1L}5ERE7|^JZhi za8+P%_IoEGF|x~hD-)B%-Px44q@#IyM@7)pH_J;C0s7#+EJ0+i+8tEb`8i|RM-&dN z@7hGdCnIA@1tO7{dfji*T(>uSQ#={4xS+2di_9h(B8AKR8I5U-E3I?{Itn~5vY$j| z>c9S_@dAU>=}yZ#e=ph#+=Y|N*q(34%}wr>4-ZGrDErP{r?R0Y;ZTM<0-2|SOe911oqxf(1~w}V&eG6R2H4K`*7WhmELsaN z|D~36KomaVe3LP@tWC1w3RZx7_*wH z#MGV)KUq0sEzIP>k2;o(vWYZVm}|Xiv|EFZOj)j6pL3i2)zd90;C54_b;FkHdI}s$?v$0=Jv5K^Abqs>+S$LMI2*0y zFMr-OXB<^u_Q_8z^ii^0TMr2H1G8o+rpUU@+a%MR_Piwj$`uFDc$Q=Sixl$qW(`C~ zK>@?V!^4P&(wEpQUJl@2^b9y=|48Lt1i45#r__KJ82tsH0?-zjWjZ8oX|RFf}1LwMokUSq#J zS! z>=lWyPln6!A6!i`Z0v3Tf(a`%8_yDS0y;i%adu{x#H^!fEC^szKu3xuE`XX<`d$8j ztod#)n~>W|J$DD##2M|5q)N;|X4UlAOh-~ghoP7I;GR&N@KUqYR`=GkZj`_z40552 zqHLe$0S}-?R|37#8zyoNQ`BgI3#r(v*878!kud^U0qQu~$G;sRY10(cxqk zJ^27cw6M}5$Gx$ho}Raw?WUtdzmjRi;3xTx9KswOS~IrP@fj*77$fXDXRH0(V* zK0KwTqzo)KIqoktusl6IH8wXJR;j;5dQp+Tuj7CPl;u_GcFxWPo;N3eaySmC3`mb< zN~EbaxA9nXWep9f+S}WmPFHmTK)|GE`10=6w$SLw!9iF_2?HREHUs*b&>f15g|&X9 zF{DvrS_SkrS)UxUN^gys+)DG8Q}(r=ZGv3^nl~IB)z=z~fa9 zDAxrZgI{=OU@!UQDyr+{H!8)8|C3_@|F4`a`2W9=$Q$1A>%7ja`3VgQO{Q z=)*7{3AzA{%gf8mNbn|d#gWb93b56SR*fw!$*LUx_VbOatE&~a)>!RLb-uFbr;|Du z4Gj%Gc)$e!Cy44bq-0r3Wq^U%#v$Kp9Z^ zrrO_J(9YUeT0r&Ba2X0pqg-VkU%U}035@>*kN!V9PV&Eg<@B#9Dbl&d>vMI@-{&Gc zavt@!E-?PoJrRGL_$*NQ9=0Zhi(me!=ZEJXtkoP!9o-f2>-}+p<@X8qnR=#qXd?ok zXP#T0`X)Pc5*L2-{vCmN7Y*nSU<6X`>%bY7+danHAZ?j&JmhUM6w}Oy@ME^(lpx%a zS$~DmLQdOn0ry>VhI5DOx2`qnmHV%346rUOvK1 z{BdMm%(rN1T}1)R1-kLu`ENd z#9x2JSY31cRv^5W#=5^K;uOTe)J;5*BhA6LdiCD+*$0~+#>G%g&S*v_(_)b`!t&2@ zk6^UC(QFk2t=Y|8WZG{epJ~Li8vi)GA^&h)+DoWRI8u~&%F_iEox);}l_+p-FX&mT zC(i7&P-mL8+}f2cZ_nmk@2eL@O^e45?xW#wCGvcucn{9jdX7gujV`@!r;v%VQDhX` zF}WLs?Y*t2jGNR}YsxLCYp%OGPPz4bW^M(eiEUq#j{s-5C()0o9H^ximn&yO11fc? zhS`0@bDVc-Ox-53reJ%9EJ?2t7$o5%@(fAx~6wy!(hHmfCoPiEDEt*-<_ls`W857_q(QAfaXt?FckV@l%?b z$koH|Y7t?^-c-;w!Xrt6heomNG4t|BF};*@?R?h==Z+qiBpCl9Te1*hRQq~f^0Lsg z5yi73dkh>6lB#X{d*MYAtZ~_cse5n8K-d-e%fkbCysgdeLC(?cA{;-pu`2-4AaP^c zC8_O#)A4CY#p0TrF@;=g6i*IGz%iwcJmRP1v+Dx>+t1z|PUxSh*U=nT8}-}JUu9h8o(jVEH>V2EPd8H^X8`yZ+>epnef=PM6WT58@=rufcff@k znP#|K)7^hcS*1w{Iy4 zsb~6rGs8UfVas;cV>G_3Ll`e_;3ggYd5U*2Nhjl$8%}3@YPK|{oo8f88Z>gB7QM%0 zs92%TNUMCp5l|tV+P`V>_{+dKhM+B537y{)5b)TBssbT{j3di44Y_93(A+x&YLAz_ z_k|(I?6vN#2R%esbEMxWZR5LXrQ@OQ$hsXLci9gF_=bb|>7C{ecVcOEls5t#e#khq zXP1I1A;MY6HdllXQ&Qb1GERXfI2OIPmoIC>6X}owl3fNMpUB=S$wO~V6YjI52HUe7 zd<=SB*6d6*0JD7HI4pNnm9~Gh=KaGTJq>>nM*eiE-=anOAlk@WmnUIjg7rj?Abe$H zcfFn9$hYo%%d3GW&D)Z8qTaWvzFxC|#81L85C*Cqwg`8#PumBvSi%+l-t}2%7t=?04{rkEdVVvNp9QEWdHr9xa-~hbg#r~ij zhw4~AgDQRf+$ko73&V;DZRFw3JX7<@=};IB8$u%w!LEx4zU7$)eCm5O!<&(!l!hB^ z;*v|lz6-=s?dFIb2GxtDzO_3;R~kJg&EbSoGt6(|?$H9aO#DlT1nP`Q8GcpaGF>ea zC+#0fF)pIYQGBBOW({G%;q@3DUF(HSo_${u3z_X`i~O}!hypQIQH-KDYUui_c>D6F ztXbIZ{DK;B&&kS{g(zQEdG5sU*hs-$rM`{}oFe4>Es&ncCv!HIJ9E>@js7($R=*>2 zpL-K>mvHjPw3+vFzK^1axI~=!akDgB3?ZgGt_7C!%$)4kRTf*Oh8!#7==$O61Q~UA zbgKR7w@#$}mu8jAL zkn+3?@d5u)ly-E@;Mh=~DUY91{g_)uvRbcE8NV;j6TV2@PoV{d=6}~(xt(=c-^gDW zX4t9gop$z=*N4EFGDfv385~5pM;6>S9={FjzAu6r57mi9m7ZxF%xG;QgV@dx6&fQ3 z$(cv*Co1;MTA!ANQ9az~j=gTGFVgEs&>G_j!DHnLmCsxOAt4q=$S30udJqLH2lCCN=A+)uDGBAQ}^Un%I?c>i5=k3>bf^Zh7)d#MA_+ zSyXLCU3HY^ciZ+??67Q)J)(2lTpc2>SbWx!`es+SN@tE2v#-e`_U8A+2z_c%W~0hX z7k@uCI;DI9f#4OxzJ({_lLZBAUqMoOEzgW~^2f!w<9ACOo6K;0f~P`X{K9oG;tcq_yBG-Kt#v(+yov34*v!}I4QSoOH` z?mPX1=ERm%l*+%JqWouX@*hDv{~wN@L}&4NkHl1kOSk#0>wp!cY_XpKBp^gBx8RNwP2h2a*KZ-XWO+7u1K7}?5&2zcy_|r(oC2Uc}O6ew7 z>Pbo^6JQAgLyYzEe*s{C3iSG@u>Wx*|I>}z(U_Q8;OF`Z@g|2I zm&cD+${&==!0u`f7}R^^bZOM_4+Gl?-?c9;f0OW$i>oPYMw!+XPqHVn{9GJqyCLU1 z;z(ztfQ&AEd~EF+8804u8OsVbq}ewkzH`u;+~D%6DE3pt$Y{x(-6pGI#c@ybH=L#x z-N{zwGyI>W8GLCxVQV9n)O<>`l(P3t^iH9Pw5T8PIA5C48ob%5m{pgxnC{QV+qXls+xC7fCT6DCQ8Y1Cf&o&4Qwbm9qzHI-O2@S8p#R#;LFUQ) zHDAnE`?02Kgm>+3;Wtx8Fg*>-y9F&vlZ|s0%EhZOsyt|0&vQ?$J5kS#G58+N>)rRW z#i~$kzRS#>3(^iDXDg*7%h%Sy6;PqoVAplrrEbLgizH#vrRYXj-u zcLMUZxybLYTV!IJVhre4Qj*-*9{c6C^v0bEHoIZ9o#&mndmOFzfqo|_?3B3VJbqCB zL4~}u*vusV5X?jP{F%!7^JSjSis&Z<8}dsgN39663;g-1wgt|+iI1JJRhH_}Sbi zW83a+=5E}W<20HKfu8)FKUwW~(S!+W`q_Mpl*qhtsL@d1X{Jj~6`!UzR92UkGls*0 z9h_d=GofSW8MD%ncquRNP9_#}vKH4%0+;!>s`;tiZNhZ1d$&=kcB07TLP06x*U0pt z(OiZxQYrEUlWbS^oCjfH7w``%@o*p)qTX z8Lf^;9sENa>2k$b|KLaXo`puf$WdZEJ4^69sNET7*idV?xD-l>*c%-eObYbrvMAy=+7M0W4fHS*84F!RfQ*hT5=%yk&pcYWRaP>~{U@2yQqkFZjoMU>BpNm~ z!h1{$sWsQn(yr;HMSH5o zR#DAKEiETAA#JXc`%rblT{y=z8McgGr#~8~Ks!~GCV^hzqP~|=v#m&lZfqB{COr*` zGIoDUA$y0yyb%#kG<6l#XXWO@%2E~;_w_eBE{gXc9g2^SB2W9i+`a43U{CD=))OXW zm`bU5XstqO$N3&+%@$a>ylLYj9+Zd@2wS9uUbt}8AZ-~J&OkD6m8n+wd*S4+2Ejh!aMgJ3XO)v<{I*lIofgMBTc)5ydyF1a4vR@BiRHVg|RI$X*`G9_~G(TOJA0;4#iMqtQMd zq`Y9TyG_;&V$^ZjxSwyuqmph@c6Kv>RL^>_YOD|B{zJOA%yE ze?IW!i4x0-e*h*&oaSczizCGw>S}&|e)b5e?fBa5?d?Vh$L1%rz(D>IF#kIS{e=sB zqy}8T*vc!n4JVVwQpplTt3<1$_u}1~LN@n~=0Pi`Us1ltYE%@MzLu?ZIoQ3Lc^5FX zuAj+AYMRCU=aPkuWzt-rK&)habdlso-7yS<6v#iswX=Z9@ah-390r9Ho0sZTMFz|eq3&Vi?H##~fmiN(IH)1qRGkTH zm0f%B?$7IeOlN{9m6PW7V`f`i58KaWN(jJi+y&FZHDEKH({hxZzRkGuIuI&G{L zzTk%L-8P;2_$5F%TuWS#L6bwZb(Pe|x1W}5kg1UT&9GXku#*Wp5w!6+yj=$wqBI6> zh}k-9Zfouc$RD=7DC>1M0O^d*+@O=~-f>yZQZQJugx@#baUPyo#1g#=4IUU~O7|T2 zoF$Few0}zKT_PFkXJ_q8NaDg6kOhn`zh26>MLLZ>^H`KE;-`d#fQKBo^cH5khh~eT zda(6}0mQPNFrenOoF#0@r@9;!+_s zgVMtstndq}hM!_XU+vgw5=7l%cl*&mO7>enygKGZCuw}y2r-C)hG7OFj@SK8!pLq+ z3#`T6s};S@RzI#Q90ckCp%{#Z1?4>Q?Q10RN_bAB6nDPJ381$b=IXKKymjf+3}N(B zxf*a>typVmhF1@_LFc^eIVVZar2Ksx?O!0hgRe_z2s%54gk(siE)9h8dRJvu_d5~? zJ(s3d?YaeMdMoClbGH`1t0;e4!Hr|id|gQW+X{<_zF7ZJ=H`$wAG7!R?zWZZlr!Y;si^+E z>GXx4+bsuKW=u1*pvj_G!TRVAqOcEsBk*0Uif*Y@YP{|cNkrhYoq*9)LoxI9qD6z7Nzrh_f3oKEEDoV&lwjQt-j?i;OW>0h7w@j&9SOR+^O() zD@j;Vc0ZF+Y`B$jyp5ilYf`ma96yV>iB~QJhuz$XFLNH<;B+6|4zkyWaRofV>E>xV zf9mH}Vm%&dt)14H+ju(?b!#=5hV_+P!MYMsI`Wu26{+IZwfP~@J-7u66A%#K+F*?p zPf?R+K^LKOV~ z>Y9al^Zz&rZDiO`=JxtHJkDKVdzZqMklxJivF?CM3+rh~d}t(&FlhI4Op5>`n_yaP zXt(L;n+kiOw19CbmYa9z=u8*eF0oE*pWzXo_*&hI+aUfRNrfzH$=vdGwFPTNAysB$ zqK-hj2iMWohvEs?>~D=cdL|Ne!e=hZTRg;xuF$j9)=n*naZVsj{Vm?^;%y5U(_1o3 z-Z?vNnu~CPyvb3+w|*Y_6BSkOArZBuVgH&k0J|Yi?MQS@wv}!X@x>B}kVTEA@F` zqEm+N53iBEend8Wiyh;(S2}tW26!EJ2|`?G6KbPb#b0>Kb22 zflK1Y@LxmZnaOudP49AHMJ7uo*dItAnW`)Ay9c8TA1Mbg4~z@WCxLo2;AwA~4&V61 zB`l8a3T5lN>;CY#XKlA8n0<9H4c^L|q*TPyzx%QO<>wk--67GC2ae%u>rla4r-4Oy zv>!7=3|ZSoUDX7bF?&LCdU_XdLzu!+0sMIJQbg>kSfra`iIG>werm!c8Yg$Yy2kdy zFX6#*t43tL$a`bSKeC5l9m-DUp%tnZ-bNgTP!~2D*jkTg6V>9s#Cw%-eWC70W3uGh zX|nX&@q+;rW`d-kV&4oe=MFxbW1D+ks{mxTCccj7Z+aV4cI*dA8 z8ktc*Kdf$N3Ac6j( zPCw@vjiB1VKgkw#b{NS~SYmskkpg1KBk18uMkC}lFca5wdI&U|nI;1nb`c)T-P|k( zEF;V=_~C&@Q?jc)=Rmp)eVsj4rIN$o;jmlkqSBVB#Eo=s|BpZ%n6@Dg-^4@5jZ z1ifNOh^3>LPW+Z4d>_xgQ}EDTk4=}{>V2_YHs}*-QG_2Mtf`K56#}|c`!0ll%sx5T zR;aJ>Xu%m*=);4QL9!I>8A_n$ZU#xG2`|%EV*7qMP%>vL9KPpwm<{uuO`s27^s(nE zwkYogxmC{`XyXLJy={Af(hO>2jSM+`p00L$SyIfJAdqm?mne>4%lo*gmn{0u!}!3u z)ff@X$QQxX8|JyA3S5^rI)YaecDzTb9y&QFLQG%)KO4+}GQf@&LK#0e&i^F5sw965 z3JCLJvn|BxO^tpOi*MC*roXBNWB~+2Omj4%oOP!9JV0mPnx{68+q~c zX0a(@C83V^nPe48M?fgApUl95<9N{F*+zj%TP?xWr+`?)SBEjvIC%*9x^$=;Q=;O?9fPT%t;b%$!`?4hh-jna)h*I*yF zAKk2H^#UFn;t^Q4USnSw71a<(*;tt)Bx-Zm!~FGNyG9Cnnz>_m^1DNXTmNy?^6wYn zuu17qF#PuW&ZCQVhWSpsf26eJtRa6s+z<^52^H^)Cm&#PS!@RyBDI$}&fM6Y`{unc zu$(F)R<~?padrBDnqS=T&zYF;1U83zIj$VVYg1WTf7sLr#uT0WvqrMR{11&pOMPGK z3uEtW8OKXhNV{bzd4_LZ4CW`Ow!)cK5pRaCw_%;ynt1VWd00YM{UCQ3@poE;*%~XL zOa7TVl4`zS_A6~g9VTc9#jl_qh0H6+HGNVkV#VOV%{565@s&aCmMh>Ywwp;){%WtB zZuVV+PBxUtG>;>Tn9^wwPSMm*19ZT z^7jadSROSqvrElbZ;A!v#D!Xq?od0R=&W%o(*NB}vX&W-N6+&LuoeN%*zD;_2_)Nu zMN(wX^Uk!9l<3HQuhkt}i1MGulPiz_l=xYwJo;*s^CYJ7p#=F|dnk|H>&=nqyJR|G zP;8>6*H`b&dy}EG4gXMLv{8GH1~o?=q~0v>tbMEU%=ESBWuaSjkGM#A9glAjI|@+= zwIw>^P(I%>G@h!FzDAUkUnZztZvAOFBs{Xm;I)?7ogk|{$V(J24Ey9l;oP~Jtk&h{ zBD^s|!+!L|Elu8*UE)DtZe@z-XG)}uwL?VrLMZnm!R}yWL`QvdTE0Ot#zqwgi6i^{ zYo}i*)LGkJ@p_t!K61dL?w450&GN26mk3kAG}r!Gqp?$`5y}byE2V|^L|57F`>rk+ za0RGJR&>vF8fM15Ov0~J8`2vSjMq8lE3bQk)|CTq^RaV7@w649f6d;$^FjtFO$@gm zci!!6Y}W|ylnHjZ^1}^QNa91u69|F>G_>F?;jz z9-*SESHf<$)%H|kEt2m(Nlr>o2WS(E3maL$caV$}whREO;>};#40&>KRF&^gCA~)kmT^gj`cJE&J%gakiUZ+XZpjGSRlr3nV9QW1R!jIkBuJui9H)Al%VG_%?vfuY|yS8yv3DdO&i#=$0eoGQ06sO_(%l4A6pRePM3Z2Z-SExX#%&CuyM2aH0_^EA&2K zkOva&XOQyYV_DskiQceHO@D^DKn0+McJiOemUY+P$rhDkCm;Wg- zc?Z65GS@^{vIs$4W>W*T3%y%PC#QYhm6nz^N@!5+Fa>JhUbI5dQ`ib{$qiNpX2w`u zJw2z@UscfiFM(DmqNC*yePF3i`|SPE!1+dq_d)-4AbSAixv$!t#B8e*fu~VeoBAzj z5C}wl1BiKMW`ioBxcs}q+_26qrUi5c=K-T4B5{#se`I|y=5F5Xsqbn zV*1oM&`6y+vk1R{<6Rn{XWhEL*zEeZ=c$KsYMO|t&#l!)19xqo(7kJ|HD!T)_w+ z(bq~$|0;cPHZyXya#Q#-^Bm@rkU*apkq5fB6#~lC7 zf6iHpmiYOfDw#h}aQ@YB;`(oIq)yEp9;R-No9o{gFw~qzFKPkVZVR*K`Y^nACwtQN zv4b=s2;p+bbV0+Wt${z#E$EG7Qp0LKONZ6N55cni(*=Ez==8pFJRQX~Mn&0z-KVFe z9YI){5yu7kMZQXfV~zv(OE^3})mE&9J9|@WR?w;s;9=U*{stiuJ)J`+#zSMT_V#B) zSWEU`UC-L0NbrK+Td7r*HrF}71ik%J?b1jX0&bLcvy!i!h+Q6fZ zkHMsHdzDLyyPwntg-OKePo`rOW||AHS57g^OYjr6>%UiJur0b`9Om$23rAu*Pob}- zry2N>)IM-rMKDw^eTQ{%DRTl2TQG_pge0}&#go4~wCm%KcCz@Qh_z>Pg@wT~$FbEa zI9$G>On+SI@?4~Bj~+d&9?EZBc)Bo27n%V8)ZaF^lhe#L;_1(4>Yw$tfg%vVXpvf4 z^` z*=igA@BYci#jAZ26NavBwYPktk373HHqaXRbkW@Tk*8rdL6qq5qJ1;pHs-w1cs5!h zA%CBEqLTByetYZprMpm|r=Cbd@#ZD*Zx*J`T0WuYyZ}*+;)4Jc$`qMW6 zYYK*$09ThxrPZ_CG&IgRtr{qB-PAw;;yXdFB4beU(4cejr1P{Mh&=t z`j=srjWxaxffZ1!mswZAb${)jCM67|MUHQvjar;zgf!Lz z`Sn4)6NW!s1a=Mq#tW-+N6Ta^%=cu%k-xADOy{zcz@`#3o%Siyb5{YnA zjMZz_r^t4j>+S{xfyz^<^r7>+`b0lRavPejJnb1a4ff-}@sFwyM*mH#3U?>SA~LEa z-Y5dJ(`pO5qlId#Z+W%V$HlFS0$*M}qWf2a9UI$+hwC3I-XRDQhcH+B7fIF-9s-UrV^w(RY>h^aZx@`1f&*#^2`y(lM z$tj;?wc{mn|1wmX#af!vUqR=ZxPhXSV9xah)8&T5!K)P+8&oTQl0`jAsoH(GPqH9R zx0|n6YY_fLzMJj8V^#AVj(2Bgs4TPh>fkNBAu%;Q5v7%kNjup}QO8ebZ{+Y-l6yzz z(+!ip$(XWGjgjTjZLjAui6GwlAO>b_MJ5Qz*T<+K@GN#GhfQ*(-UmCJ1XTy|v$Y`o*Kc>0Oi5+)Lu}TbE(Ss+bm# zau%PCrk^{ZqC=xcL|fE!je$SZomdk!2xreDiO;Ppg@n`4Znq-fSr@wM!!AeEtdtj} zCm3ZmoKvGdR|J&AA#>zWetg@m2xqJBZv3tJM`;jlc76Jz9jly6UVwps8N;a2UJ?SL zTj|6St3El!F?Yn(dv1?+FApM7Mi7Z`3@4noKV>6J0)Cj%3(kp;V5F#+FPbY7PRWaY zb{tyqSzkU`B6*bN@%gK|*b@UTxmq$d=(6@LWDO0Wn|42#TpM$!PE21~_GARb0 zH`lPYw-*aLrLP?KhfwHN{viq11NMr@Tuk}pM0k^r>`sQRcGHar=LPSGUQ1*ZrPG=+ z`0qYaqOUB|*Go_uQ>NWsSfQl*ZA89$Sx=d`p*%*j7(Hi$f=G2_^_GfSYXB8UKhz-( znPj2ipw5p#t%Mb}JjG#tV9Xi?cD)}Z`njI^tCqc2kpo9mO^?Z^vx#nczB#dxH>~+} z5QbncRwq}E)X1vN>kVa!cz@cAHi&&~tQ8!oJyiX6p;9aLzSVKV+l*%S#j@L%qJn&N z>L_(Az<-{-_n9?Gk&cGuT;ec;^(!IpbgPym3-+hG-z>xQfd7+#&F}^07N})$uld+Q zZ201$){u+YGev~JN3th{x%lA8S?6RIe#`NFrR%9?kVU58@2oQH5?VJkI?&P^KyCIS zs*3ArUXQR(UG_)0`XxtOtO)US^?Bmc;hl%gt5VC`_%XzW+Hl{nZv<)P6xNq&Z2o~S z+D=pGTppNXe43@3l{^z<>a-pcXuJg3gXox4q|FYBpGUu`TVxRHUvft^)p9to0ERZ= zd=_fff}6xuj;VaF$Dx(FbqKkk6PAk~Vt+y-D!+abk{#+UaL2B;cM9JGL1X67iZcl8 zbjRy~{DN-L%oJUi4m)gQk|4SAm;ElM`gZkUyw82Xq_%RpU(V|$bPKDr72JamkG;sD zz%)~q%ppb{bT79uw_exJI+{XWEtsZb&r+w=zt1gHS#fdWK@mEkKl1~P-3y5lbaxz- zdbD6ew(U!~T3)#$MJ|WgVIx47O zt52*+q$+t_g2b>B()N3;G$YE7zKG;Dl#>f+wMA#-1}XVar>Z-uivTp>1@bnyKUoll z%9K*^>7IIDmHnU>fMq24RPy+?m7ZETz+vo|_*`tJ4vhxl#jSLbcmb#%&{ZBxl>p!x zan-)h+JqKPi!aTNca6~J+I^g(0mHlNy&clqM0tmYj`Y21{mHk-34gj%FYY}hT-4)R zA6%8;G%KW(V?+wi+yb3Ezx}E+LOQvMYaGi{s9dr9^V{mQ4$h*OzRo9%=n|E~Q;MfB z;7&9FZ!EpS!Smf_1s~?^q~T2Oxbxr@>#dzq9yN0YL(My~Il_(bTRRguCI3&OA5Atz zZ<8Nar+U7Wz_L=xZ9*^tNk8Ir@f>-QwcgfQtINCDW58Aw%~YK zO&=Qr+i&f2Vr#VVN1ACosR=`d)5qSiRqaG557bT9DcOb2GjJGk>Wyarpx3x=sfy zI^g(8rJBUOYg$kZkr}6NpXm7uZ8A+q_CZYf#f91XqJwb_My^zzF+RhE&J<57geVJP zC-?@9Euh5uy*PDgA6xfwIu?te^qUSJTSCB&zPXq#eE^dpnFut=M zp63Fdj&jF@t?9Q@79XZQ(&wHSv{w@}AoArqC6j!pX8SG0#Wp@ZU%8?YhE8kvgNi`PEO5KMJh?$qgZ~fwWOl*$sX6&(c2JhAHGjg)FsYJP3}WZQ~~@=h4TZ= zgk6Oibz!07^N;Xu2N$6$(pw?x@;{#;W;@k1;A~m_%JjG|mjQq?(>2E7D-lyz(u@l3 z%+%5^ZuRx`&!&c*5AHmXo;dHF4c;1HAuS_~>m-i+UA9`gXd6*_L1np{RP^WLa(MM> z{AB9-oT5%5NE2jrCiB{n z#|BbC1qH5L*@XnFb@NtoF*~}>Q3}@ml16$dk~`-)lF~_b_~>eEouP{81{PP1y!{ZC zhpd2-68gQvcn9UjSV_1^;d)MNBW#S`!>^mQufgGLoW67uXy(@JSo^&VLkubMwcL(0 zS_TiDKjfRTO$*J!nBi1i%&DjMvOrR1RN@tgT@TQGr_ioM8ptl8#S7^Ko1o5W#s$NF3!c(*>&M`8deBMtH@uc) z5Af1VR$0f_kI@(9GLsf$`u#j{1G=fhp=)QAx+70g&#nO*tnU&lVKszp!|7pOG zUr9mpOCDEpVgH=7{U+*?_4HZBLhFIT-eFRLSv?M^7D8NO)rV9rvtrL#Q++RYjHvoa z1)bdQi)dOX4r?ecm=U&&!{%qB*^(dow^gi#?LX2mzBydLv}Iwwvpij?jR0O)<|}e| zoZgIme2w{GpAdD!nRx>{w+y>Z8W+Ejz6R0Ea)%|2R|ea=Mgl=ngZt=Lbv=1~7LQ}Q z6z_)3V#!6bQVqP`S09<$8}p{;i8i7#zciI7cRI0Cpf`A$*!Q4D`9$4XTpaJvWEkLo zg*tmTiWl5&>)M!e?!@_^#ieZ4ey;^n=dw=h2iJ931se4X`N3!9@my;*7>i5_LIF;5 zVLtcU@t~Hl=|M0YlZNenAxHPP*#xl2REoEFyDoEw%yWRJB*2Dc%ui?VI7^T_=1#!9 zfo%)l_5zvVw*tEgfDS1kY-G+r zg&`9m@@~>(bWJ{gRoD5-`!Uz3?Ug*3-yM8Te2_l+rE~zHL-cycz%U5XXwa zDulgUi-Tv`2gcYZRFZB2Q6965>!>Zkif`+sZ< zzlMO=92r9$;AkIBT_##q|K<{mvRrP=L7eK`ym?Iy3mZ7{NjLR(emIr8-8;XS!CWa) z2PjXbpE9-o&d{aBo49j%MkYRGda2ABCDe(fDixb4<52bNTU@W=9)_p4gzlf{%^j_t z-m`GSlS8cHx2E!wBWyd`Dc#~+@h$WTpYG230%e^xM0r{6l{G^cw{qTP=h;t7W&6r#5!afMZ8<+@b7Kx(P4O5nv)RZqS*sby5TSlDhj@z(iO# zHQ2;Jw?yZ4KG0aM4F_SLdJqS*F2I+(fOPpt>^HAI=xn)V|C;Be-=kO?{3%HP zdVGJRDI4B-c*)Hv<7=qQ67Jz6Z@fe4rONd3wp==SL3h?pluc<_=eS9nh`seo{xUAi~N1BLG!nXLCm@r&agj|n5euAhw4Ry zfo!DXv_02p9KMvBap|MtsYE?SxN}FYM11b{Uz3%6!5E`z=e>sAgkzAG&bV9nWDsBp;3cJ3U*%{~@H_|E3q4M_IemOJT$1qpX3$B&*PG1+PC|Dafy% z+VTExZW3dbdt#I|_&>blv#;yN|C7ng|NY$muV~fJBm@4U-zwL;*%fas3Jz*FKlKjW zwyf;dN^{<{`q_t@Cr;_*TsG^oUmw_Zgywi@UsK>1~p^}=8AhmE0ShYCl@xCb0?LZW*R;$ ziM0M5Xv$hW|62`}m6Y7uvrX3fiJln1QZ52rl~(_iz#QC&@-*~;feUK$w#6@tN5glt zH+9vdrqH>y*|+~shnSI7(^%Wdc2^_XLrLS7!H@BoLm{X2B}d9!C_#&4)@C~)%!g_M zoF-loFGsSzdIzMG`$uCx;J;zqvIJn~MxRCP;;%}r-BPSR66~t%lVengu-p1Gpn3l}@Qe6!GDS$r_o^P8;$2UFw0O%O$*_mD5 za8~yn8DE{c-8<{wOY#hx((uQ71)cuFAwL^TA!QP7xh_q7$N}5)sKO(J)jJZpgxIz( z{fU2zBdkc5&fH_;WR>&|uLsj*a%RaK;zs%Jf6xKikUSqevv<1?4}o@iQuyh-r|KWk z{x#a}zw`a-&5rS*clPAE@Okg@GkyLI`T41n??wI4bw5ze2VlWO^DJ_Cagk?zcA)F@ zqN#qEWgDir(B+96Tl;=oc+VJ|M#;}Cs>^ruJ;F{NO~w3b=V!_s)@6@S?OV(hy(u}+ zsi={Cb9!g2LoVg#)kmXyz|)T=cNedXCs7xV++)?d_r+^CKMQ8PPhBZ?4uvX=E-eSq zZL;yI1LhwPQLs_aG#;O#B1e@R3BFcm(Bh2vCQvWOUU5gD5Q;4{o%4UBdjW{*ISH(A`5W~ZWTKx7u`Z1BBQ*- zhL@6&u??&QxYGmtuvVZBWRtysH7dJ-f}`t7cZq|;&WoGZyQ#qb#eHDT8gWsuVmJG# zvX+gY!D6H1xkTm-Zoq~ok}7@I>;#ZgB>fGX3pSP8U4O$AfBnQ8KjOW(?d6akW#$8R znV|GCwzaQeZ|6}iu6o%jS*xBz23wPnzv+E){`eB-j)i>olG>jO)>o29W5en|_M1PR zzB;CVFtQo(b0s}<*1lSPhN|eU=M#!==--F&{oZA%xs)4?xPEx?@<(*6^S`t_2VK$R zOZZ&eV9{{q33&hoGP7l{-L}Xck+owxJ!B{{07CPXZwX)ge0+cRQ>ttf6_g-N$!(f< zoHR1MdSc~I_-|mSri74ch>oST zI26aPcfZVRM?ctdd+53Pv`4ggFuokQ@=$${aT=hwa~U$jHD%4|ey5=~v`Quy?c+?y zk*{M@?Y-@tfS^5d7ix3#ohcIl#wwCq6^~wZuiwvd&)5)+nTQa8gyU_7@yhN$4 zbb4H`aXj@-ZDL9=VdQ{%TtJQg=n$=!i;vdNsO)$4+mYM; zTl4lf9{NA9ZQ6?5;vbRfHM|(lG$g6GpTm%c*bj2l zJxSa%);S`l28HEIy3r;Vh^n`-Bx&{=Sv?~oASB$Zo2$6x{zD)gPn`@IR}srU)mAO&UrlfkzZ z^&I?~ryziLtQ*_{n`XZsQ5qmBy<^9de?*Hrbw7l6r^D?)m<|4Qr55lmPEu~X$8G4E zV7Hsj9w$S_=0(oE@Mr)`(N~Pq%yKKtS``CVr*CQ{(>$hFg@Hl6fCFe#Rk2Ja-vo=| z>;I{34)WNuMrFnug3v!B*nWRCuB%B2XA5SZ4pgzkbu#Y?vzFMj;A&m81_vYB!E$@ixXESzYo70XrYQYB7Cp9QG-# zp5V6CH}88HXTM8YT59qL1Sk+hSylKJJSbdU4!Rak@#R{bIA{x#W-74=J;MlDiR~Ba zv?qo$OVW;p7=Kt+d$$!G8S#%#Qk@Daw@A30`%V|n7gPy2uU-b3`7F%367$X$SbJPo z@c=%eX(}3wW>|0Nt{ZFH+;GU$h;-RaFT_o3sIe6DJo>qu$19+Bfi`ZqgeNu(D22Pm z-mNTH*}wd*II>w9UqJwI^AV7X-F6wAbCC&P%uYWexy;AEb+;$8YKC<8Yxe;U8YT`d zClV*Hy%EzwStfmqwWdj$<&YedcDT$<$mmiG8+QGPGPPv*SXZoI+?5eWcADd)Es9*& zl*{0wyCLUCl@0KL$ZJ=B{2lW!w+0;=O9pL_&vT78YL;gp(3_>P&RW?63Yw0L5Hl7P zglDR&*AGfrbBdd+Teaq``7%3eAHVtwo5FaxXBtYifHJL3U@vFD+Murk9b42JX+AbP zvRS3Wj){hu4j=#sD{;bS*}G<2*op*&wNP61Tqcg^^y}VnV(T6Ez|!BP`~C*fW{DiO zqx$fQ?{^4NYaNmb)xpD@_Yg$05D*r`b<(h(x`WYr8A6S8XEqXNGhr-G8F} zxxohP;c1Fx!>L7VIhe-f!#mO)^GMYAfsuw>&u9+n3Hk0bYMl;uR$HZ?EWByAEKn;1SNl%>8x*Pi9GIXg>FP{Cr=F)T( zZ~v-t%C2TZj_X9UI5u}coO$kaEm_ls7yW>_1c%xm^*U9kjO&838pEZ|eM90l^QQ~# z^+AbIs9V0xJC-Iq+<5d{SPNrnz?9oN*<8ot4g{#kDYtdb_hM!&;VJR$$|jvZz;8#} z=|)XIoX<*_%t*$R^*k4t4fpwWHFd0Q=sT)Ji61gGQ*|*8jo^|xjZse`S^Dc^s0L%0 zpoKQ{ivB@63MWgt7)^?G!;7Tr(Zxl_I{l*`nfOgnduZL>?hYl}qjND~i}j5hxMeaF zlIRoN9Ym7mUOsea%$VAVRiDj=$gMUNnMo)d52=ZDqLH|h8YFd(L}~Z06h-W57g#M; ztEU6Jv9!ftwEn_=USUGTo|q9fk+nRhpF954e{uF-akq^1T_abo9toj?m!WLISt=yq zFgsupATJWih3YneY1Fa9*yK zUcKIiRkJzn@q~DbbaiF-+N_Z$5+%b#o(>ySwyyf$PRk#dCvQv4hBE&)?tT<~Frk#Y za@dUwLQm_Ml--(Nw*naXSiK^WtR#tUu>hl<*^}pJW()@^ppsMMLUOWV@z@88^An65 zogQiR7ZZz+DHbAR&U(iTsml8cthc_JF!j)vBRyOWAA}1gwM_|5E@wvquto0$S=#rW z6LOjAR!`)+oge{~Qn4MiL3zqCIgsC|KgUHWp3%DbB@;e9Qi-K@qa@$X;1 z3!o?4s-j{J8Xv?b+U)GuJ{ClsA|P|be3l3t)rv4eE&lckNn-^bs~s-x!1Y8kq-6-& z0~8s!XVv~%-z0#oubaFuqEM%x)eoGPa$SQW%b|B#t#(oz-n>KnE$qJE*{-9wAWeHVF~d~N$wtK3c$#AC@NiHXtmkfz;} zlkSVZ{oX>^1m_^b$0~9Wz&VMpK%C#BQ05)jVl3e(+Si1?lU5}iwgPblc?g!|5SSTe zIon4E#O1PuGG!txA6j0ePhg9&A#gDTRlXY>qHEJaX6O&B$(=HIH0FPD=NOTnVzwyH zP8`2smY1V`ACS^;8uzvf=0|)0&sjferLhRliQQUz|F~@ff06Ij;(kxaVm28l!km*E ztOdek`C>O`xf&~IYtj>Fnlhy~4DXHJ?uH>)W%cjblNH{7Rvgi8z{Nx`+8q6$*g6uG z-m`W`wZClvGJ;w`85YtxQEZpOwHLlr%Ic1x@)=L)OdcwUj_>BO(!Q{rZ7S+Ykl*fL zeyA38!X6-_qn)GF2&v z>=A`TR)^~3$=_1z_(MBAz==bSGHz&$DQ74(OT1u}>=%wjiRA><_@)6b(q66Mq;h0s zlNGstF^J!%dSyWNcuj!*wjfX}9U?;~t*MeBtvOjQrX696uY{w;{7ick4BK0~MRxIM zy@5+432GGjW{>LFZ=T~N#VFiA3X1Nfv#>QUgr<69IN zJ}edl8*-*)T{I9bIGqm&fYLazdWLvQSG@<{Q!Wc#%i4p$JcE^tbcN4Px;Z>LvnQ>r zlf933=Z8g7T0>v~2TH?jIS;2hIWWhI;U5ECkkR{g@aGlJqO1KrC3YK!2pzeDn(a;_ zkE^v|D%op@m`|w3HNHLo=9KfyUneQ_`4V|&_}1}qF=PF=_0-7`u4lMF zjY!X3M{Md?n+=|WUtK^xIajT+SJ?qe9`x zAU4&`eggE9#YZY(_JofEYJc5)<96TWm%G1uaV8=9uCabSEbT@sKTno`0;E*^9jOg4 zn8PbXHk(zi)Fnq0;oscKSU_xr@0%rKtB_r&0blgX*;uY0`c;>E(|KPM&o+QLOmBI7 z_!~cBf{q5v6ux{~Soj`BD-U{>$~hyRYGm@7;q0Y$ z8&Q(iY9*`TWxF~!YkG0cZ<55`K6`GR06t*|_@SPVo=Ah44?QiIE{|C)c* zV`s4EOtFr*m7DfdP~Y1G&woQS#U``*w@XX&97NG%JBdoG3&iLv3P@6vu_rb41nipO z*ISP70@D^kDLt?XA5hDNC2oGTc=|chQ<8g&u%!4(#5Fh7oon}3YDJtv+Jj3rJ94Eh zo~sN6g4_ij`~m~BpKmRZGoJs%U||X`Ll4=;^_#Hc2KxZu>8QBIO|4rQu%htwdDZg_ zm^SBVE%<1?<4@zL_`)-9Vw=uY1n|~Hzl-tyzL8RnMbM)x&TN};<}CHkLBh|zDUyIj z``n+9u!lk&+O}#P6W_dlV?DGQu@wPCiQ!%Qt_kaTP>1$)+E!VJIt-z72HwH$vwz&V ze&7eZ&5e0XDzo`Lg2{Hh+v`B0eD{py|GXkRG<7|AWm%JPn#&dY!-THxLkX5|?qdeh zELG9N2)G%5hDEw@jet%Ux7FVI2KMaEO{I7rB(l9u3>%?(hFQ}G#zv#WT)=#UpO|v~ zE)tJDlK4EpI(&r9c$x`Yt`4Ffe0&s|XsH;L?4+P}n)8S7g z;}$y&i+CEqKq}FvG}$ODy!Atl!HLX~S(YhR#y{!JVDF zcBvm6$3==k6vfXYa(LML;atk zXZ-(00scS9GY8d|ky`)k?d|X|LIdiS{m`I9ETmFpg!uuKRYFNb1U4QF88Id=zRV9^?nfvKIA9fLY0op z_wP8{+uO-{uX~iZxNUHSMMd%H>9Rp@F&R4;4wDuTlYYg=i)(6Xnh|~ZcUe{(BO{}i zQL7y^cr#11U;n6Ea)<*38RLJK0QDcu3<^q|*c6^11Dy}4ZOn#>oq>HL;Pq8JZy}hC zepk3&!DOZ|(S(xXFs_?{fd2MNAtTC_p@j*p`@y;Q}f&YM6AP>0*n(xNhW1Nf;@L83Hf0uod2Y`s^c+XIfnsC z!Xn7cMbgXhB}Vkzy=V{fR=*5$f&$J#QR!{b4x*ZpYkf+y9S6(JF^P6!aj;C}HT6In z`aeswa0uPWqd+!ML+_G9;tc`B&ehlMa?bmK!XJj=O>oR>rGBa93rfKtcD|uH(4v}h zZR<7`I1w)%+wbK^ekxNn#|;_a6+TUNazuu(l1X%q@;AQT)v&>DoC`kZv-<$tn(Ctc z_@mGJw&DxzT0VSctQGE*h?~}Pph9E~z3FInA@UscE1X@CN2jVGVY>0D_c8 zJ)a(#+{zr=z5NePfdxbBmafpuM_#(WU!5ib^_X)wBhuw~8^FJUew7Zo{`DoGF!o`> z!ku(gSIiu46yrM0WKm##lfl)!w~po0u*yxf75p`v zKM=yd%QwH1aY&gjM}VN~opw2jQjD}QEa$;%bQae4Fk^z^fWd5d6O{W)718IK--GSE zPbJP>WX4nKROgYvKtX@}))wI<9MKD-HP$^ogZkfO^k-wi==zCon=!5fR-pgbNP!T# zHVMh59Zus4aSC-IWA_eEr~>)h8SE;k0&`VIxZEMl-X>K9k6JH#i;?atAw)BCbMlfGW_p{ zA9R-MSE&W7MK?aq#ubq8fgJhHzMRgTL;2Y@SJP{0b2$$Wz$7B8dkFzw+Q7H^l84VD z8o3iRMQb7d@r#FX)reVN%ooU|H$+Ot3)HP4evvY4HSUo_7Pa?70;UU{p(XbAO5yN- zetE$jgzp|)VN2`LIXrOkk$OLFfY*X6NF<*zwwGyE?@RHT{P4Q;`nm72rTuvtKQrwfbB zOn6c9J!hw$8z-aWz))=HF@>o2I=kqb-6D+#w}o%*7*?@|sE|>5z%^^BKLS*cr;qHn zAAxN%fP4?9cmmsi3R#_*-{<)f()jZPP9R0Q5oC_jse?$-)%zWUmd<2gDy}Wa`lgn0 z*;l_^j3S3j^5ka$t&40uAe6lF==tXBK( z23E{`Ogj6uZ{kCSb}WI%u6erWEo;0Xu(=Y4q*oTu``;3hC5HxosN9AggbA#mq8!d~ zCFss$soXsHP_Lm!{;H{=1T1L_rDzW_HkaUkj_IS7vWej*MWm#-A?5e+sZI3Q+vGjg z_v3SepVj)j0P8$eSnutHAhVBQa97P+Q&9PkZrpq7T%yL&+*m$T>3e#UnzrixjB13C z32f=aJ#G)|9Z%J?(({mLBnJ#UvL;P)3_@(3$_q|G~sB2 zMAeF`jjnUvT(z9{i#>ijUL5OpSTJukr|NDqqw0d}hVuuZx`oD3?W=3N1{-@0#W;?6 z7Z?3~j*io3HC%-NDVHyiOrAi*Mfc(uSk^$79?7$*#!mTzVV~61-r-X1wk?|FmLq?y zwCh$Ky;sik7Lm6ap4Wzigc1?psinaRLSwE74aLq9k8W)nL7jCPhk~w;(hNRXx*^1^ zEe50b3x&4j8t8kc)F)Oco}7OTrN>RZ*`JhqRL<%MvpY#l<2$48y6=|w>jEuRnJnqX z!<7@sHCS$Z)uD~hH9gLC7nl<^Rmm*_ooFZVv5(!u1skw`89g_EyZ?Ec7gB9vp8$5B z{8EZ@)9Yi%UUIAH_f{)32Vt>D0dFM-NNGAKiozT7xbaMU*IG0Txy)Sj>5f8pEHgp7 zjPPBE$&~g&3iB)D(5s}sRphYfTnbDTXyJs+TRd!z+JyMEk~Fe+2*pj1eE z8X*GOfW?P`QGi(07Pr`94SdIMNkg=MKX1~q4qRZAc-2M$TuSO1N2OGq&Q1EBkDlAx zQBpT4-OcfFl=o{o&o*M(r^_D6Y14;tFn%slc#Zw0cW%6Z#s{ zcOO=!lj-I=KP@#S9gSU~*im_VLIaEp)7nm#yMk0-tW_~96w~SgL%4`=m#YO+#;cwM zt}O^HI%>~WCpOkHRIdeO$%qNZqqA+-Nfb;WIOQ65^e&UCgNmWCPHLCKvgWYHCQWNS z*=)4KL;lj)-@F4R8VwEB9{E8S>~F%$-u3d$@|u*b#yxfsbS`uoa|ry(7@rhz@9hIl z6{i0S%qNBPP+JX&5p`YP-EGdG<+6xf?<0e+M&{-ET7JhMK0Jw{ZsGJPiYUXo# zB4`@X;f$De08PleBXaz$&UW5az_m@__XgwncmU6e=OVgX!43Uqh_##X1rM`oRE)^& zDk^=gYQc}|41DItw8cS~Y_UE3tG1Yih`5j2%^{)SU@BKBlh=;1gZQO`o<1xo37?ab^A&yl zGmvsbU{Fv*baemXVoQCL^W>-hf>{d4uabuU*0=bo>d_tZMqbh8$B!Y=(b1!tZwm6N{&&6X_)kOQk4&El0%Y*{t;baW zOECRcHlOeifW;T5CuEk;J=z--zTs!z4@wHy7Js7$XenJiYS}DoPOqz&BZt@=oJS#S)h|2b_o9)Fa80c zIfn`Vg+))8LbLy%FzsoShzH819RjGzM<0wYOUw%puLAAD5W<*}0R}$Zw~A97K$qbM z?Dzj#IQ%KmEnvD-@x4X1A#&>YsmZkaSHTPW)T3wd7!u)_7Phb+E7739(v*NnyQ5K$ za}RzgqMU-@hVb6p>OVNF+x;qnkglc>W8ax?Y82+AD9vIS*5-=>7mu6GzxoxOn8(zc zt#YOeT=TF#6$MITpo~~GByA{IKxc#lh*~`#d=7n@u1;BuZ6B5ksKqZ=ZXKU-LIRM{Lv zr1(^=RU0*+LdW4UVtz~EjDt6yhSgb<|0NnM`$h7yOVTgn<{kQCQGx~f2byY3jhiz? zpI=DqBk3<#aW8m1n0oc;0#+PC*LL^nRTEMgx?}T18hu!walKQO_B(RlEd!a*X$wWG zsx2cM_5!kwDzkXwt^Dojcq&k291)!;s*C-@EP+Z4zoX7J`&g7Di#!nxBPfMOydOU&Kspm#(Cg8;BL$gpGgFa+zKM7^ri;3N}&Q@$C zkrl|-F9dZCM;bmn0FKoL%x7A>zZ_eFD^k{h|F{@c(f|3#(x`L%RI-cw0oR4I;IG#% z#o7W0Q~c8`z<-aga!kxmsy|sY6;*{R{6s7|EOb-;@DKNa&*ev#+w`!LPUkl-WYh-ViueBB9+VVnzAygIZWeTj^5%HMAAW@d2P2}uo8sc*3;vd z+WuRu(Kyc=I`3y!C8uF`qNt;>R{FD^A;4Bd3PbSc-UKAP1o_+aHjtPEb#Snh z3H_cZ)!`ob_;}}?FSvgX+J1N4rjbi2z%PELbCqQdG$`zkvY$($S}SIHLWDe29&&r= z*l{H<&lPDD=QGwf`T9d`W4kwO&#HL|gj-dXKSE9*;FY{}^FS{%-gFlnzh*iy*ErT_ zOqEBq7bQ@xd&Ca*3l+iAWwSXm7tfxM3J-Mh^7ufffKOjHA81bhy3Y*v?BMkAH$i$> z%E_g73g1JuUYQ0|8w8}e{D4km0}0$`JaWqR6M2vrAWfLl5Gkm*Sxw!Tm3f+6Xk|?> z9i@_=ZSAoCb3yz?ITUZS#S&Walw`nEB#o%5Oi)dkx?Y+&bR*z%+flOi1giiY>{T!? zDd9FhLP~c5kLOhd^Gv$RgUO?MowDGSu#*L{Fs5m`Py1+g`aEAA9`-pKjQ2LXRMA`7 zxs6w_x$7*TBxhZ|31-5NGQPEYE_z-=)KcUvCgNv$q6Rili<4V#P(g!8Xko)#i%u)0 zzhx|86-kvot>6Mxpmx}!VwcJyhU?g`B+;_3xi9V!w?uCXTk)#28@0{!hE6m*Vt(Z7 zB|KJm_ix^8WeQcl4`kkgp8`vs!Z-ytMj)*l(7%L*-fz~ zr_cNdb9PqEn2{5SbY~JOkxuOwY+@6VQKNWbV48M6WniV1uV!e;3N^*PV;DV4%6sfK zeN1#)rohbo(T+FDYtv`*q`H-=oJ9}&@!B8v&A4y0RT|y}VM@`>-!CS)|CbgZS6p

rB@Yj5284B z8W1^tKZ2op@+Uxo;CyPK+^&c-lRNjef35(t4EK{qq275gx0`k8=DD(IaNdsLcjfO7 zt6j=vB_*8ip36)5ecV2C9H$@6CrorCQ;`=o7B8lZE8D$r8pPhnC7G+1=Nk>_l8k=i zhV-0wqLuD$&vcY2vE5Beub;)QvY*81Ot79U(VFRc;U_8w-S=~Ry4JCE>Yf8YZHRm|N%VMqkE^~y{eUkQ?F}5-*R(xK}*}AHr$63NZOs9Uz;YeBIV@OE349Hr+71? z=}zia&sd_*u*-o!-GDDlH#bx2aB=X}wJ-+h3p<_%nW8aOSSY@mCWf?oVzdu$S{9PWXk=?EvC3Y>N z(-l%aIyb)^zbLg(uYU$RXxQ5!n}O?@&er51(cz0aWDRPn`?ImaVeJNK)r8+{Qnq82 zT{02i;4INm;O#=VBot|NgTT)X=fra}p7ARDm=tSO9u~mD`J6G=6KHCgN4^dgQj^WO z17tsSv+(nmS9bnYxi{wvA}!fd$x*@X@FZB!f)1@0!*&D9g==+I`TWZVt}TwzKSxt$34g9Oza7h5mdoCR z=^EHY{ZIrDWv7x>PvVkXF;6bUvW+FZlb_uei8HwBMI&=}tugFtR^OCR zEQb#4%5CRn7wmIOK+oq|ENsF}Bt_=)`ul{W$9_G;5INUHzIF3tEc|5OzRZM`N%DC-QJal(=t`{^s+Hm$ap1FTE}GRR{A_EC}Beu;W2mja0% z=9wm>3@z4nLv=ZnXJw{#yIXPMW!K?maR7ieT0Gope=O#lKZto#>iptIVZDvBg6Cak zq1wgU2@-!Yo=qmS8=h_oYhQN0IjUz<+bC+`EU-L+hx`20ll-%}H>GgfsEj}&LPLUQxCBpI_l%~Ez{^lKT^E5e3NKC9!>v6Lbgp}+G&OvvkkLwOL zGcFss%{^1_$dMbJriH6Y$$xivXWuX6%Xl!Wf04}pg~;6f40#G$4olKnVUB5;-EF9j|9 z8*YCbuUo|jDp%bh=)_+%*5x&ANM~FJ$$6voCgD&47`Wio1( z!7TIRN{ps)ir)tXS}a$VT=qof-%a1zx9>dhl^QSLb>6n@JA0}mxxpl%&w({JH5Noi zMX3ol6;v_zULQk?tWW2>2{`U3SaM%yK5FZp!cm3Ooo6hy( z=9!qv_T|i*C9`=-D@1)?3?5Zri0d!A@g=ovufsXdERF7t$*R_Q{~IP5FY}z)YuRs1 zt2hxFmsg(z2N(4o@n1&x1?6AH9E$TV)5LiHFRLY%_?H=S1pmvrjZt2|Lj(770O$Xo z6Mv)5a(~hL*Y-;I?Ft#*8`4X!PDTa>6gQIBGD})CG_*a4*B3qM3R*<&goK3rS88wn z@UUfd4*B)261fyq9v&V>Cnwdvefw5AX5|(t+v1IpJ5>S%3jbftoq04{-{0?PtEd_} zXqD10H7nJc)vTylNz9?GDnU&ZLkv-huPRz&&2x|#iWpN=6-A8^YOWfSn2MMa_xRoK zeb%$?AJ4k$-u3*}UF)nQ>zuRq*?XV8KcCm3+Ly5d%Vu_V*>7WFG$E(U{Ahe9kwparw0Po$Mv5?sz(Y={ z5iJb6N{%s?oI6NcJM#DZyd#Hpi9XOcY78HdrD0)b9G#rdh!g6X1OwX{&haJ~`q{H* zN$=mY5rOrBXa>(OEH9%A3m;lpS>;I-f$s_Be)#?SSw~k_RMSP^IOfuyKY!M|Kg#pw zGDgw>cQsc%aE+PqzxDP4mp$!JUv6}kiJ0zQp9Gt{%s$kh_Q45VX5_tOm8{Owk~g3J{kugKz-4|3?*Wt^Y1Q@7%p za9M(~qHc3!9KP1!NUWb%H{@jy)d}9G@ObQj#R_@x!J-u3q9J$gP@8A-Jch-?*r+%fXc2PEITq1iJLTMl<1lR2=S`7v-vJ>cVV(+MaUx zZ#1GZFe>2r(%s=(JO>(Q-MMn76t*#WpN#rf9+GeRCcAUY8du5cqVUw(4d>ki<_WM# zh1&Bn)1&y|LdIng5v4a>r(w%y4Ga7E3vynN9MiA%E zBKgheEtD%;TaHUb?{}^&iTJG?&UsB>cNCPVOV?3K_p7@=9ZnvEzSXO-tw+U&1ju}{ zU5j;P>nF=b_3jS}x~#{m7mD6=kP-;-#1=7Zw|k1~G}@TdXBc_j_7xU6^FV8im6SUr za5C(glTxQd|JVECA}LkSlkbM<)NR7yj7h4m1l+D>G^IBQR8M)SO!+Db-sLFBSZ~pn zi#IYmK&2ga{Z^tox!!d;9A`Nz&SAoS&BS9iTVb5?0VsEl)fC=R55M9IA>_N|)1dsh z+dY_|p%--Y2BRYz*#*LyrmZz2;;RFP(+~79OItIXgk*j*>XY=&X;^q<7e~VVB$E~` zlKph?v9*W{p9YU#!)mq26t}qX8^a4JJx==gh_PT}i>xbV2t2QpkwA`Wnei5@XbDTT zoCU_}_Fm)ON?eulRY6_on5Z8mrxuE+GH3*Uma^%nply2k)syV?sHew-K0}`bDpu6B zJsnH(uK=u5x7zhxoRoqcSgjX0>_g7NNCk+;UzU>eMqQWHqwZI(THK=H*Oi6w_}4YH zDBF)?DV&6IL>u6%s}*HGcbha!mP>-`K6}|zOB_<06AGUgrmk=qA`acGpBg^{Pu9QYE}CLXh=#o2Z@Rke;4L=7)dl>ZwxN0k0P-8_70! zA!t#hJFAeT>!zV*+$MtpKOI^WT=WPQt+?ySHN=f?+eBAaYa{uHt;@T@TbQMl^FEu~ zbnxx9_*zFq^6F5P-pb{>8Qc6~_xKjQ>XkU}d>-JZY?{YkyJM3lmu;JC-Coq6rU^T! zwSYQ%pYyu`m?!ygpfz6iE+&ZfE;8xqbj|F;oQ>n&wl@j)v5TaNg3Z@Gj?bqhv!kTf zwi+_+v8A%C$2xb9x6gue{1QX%1&1aSv_JnyzeCLlDOj9+Ui^+hfHf@_8)@*qHD66( zQr4vd%S7Iil7$_vJ%lX_0ha&QyN++a%-IrW3}>1uF;DduhoT$sHst=<6V#Z>1OARe zbjDa^<)}x(#&@nT(L=X#U(V8=N6fAv9mRirx@!@5Rzz{5x=k;d1=g3X~zV#d7? zcv}BkG_GRtRV}g(E68H>zVC(e!pE7z=S!||B6E&5IVKS?P;A}cS)3n5Rnz=j?8W^> z^7%)0(Vtmp%1|`%W=^5ML$kR!6(rK0_*`tfR3;|X1r}fumme%zwu1VG%kChVn3`hr zQU=DvUKPc_{ZerC`eL)wd^W^n;`$3oz;%B~d&f4&`OY#6aU>00cN0(BGY$qVo4ViJvGbX361XZmN0VpjIVwTvQl<&%N~k7KJN zR_epnmcwV$-o3odjZfmTtzU{NmPYkAKWhr{-egvmIWp|?SG8*837mMqzd={O(qC-_ zcUpAo^KUhp8}cG1DA?^8G(2RqRXyz&1vg$$)}H(YUF=Ebly$Q#=>>+48rLvS;KP2x zsn7ra06>6JoO&p=D@bT$(PF0Iwd@t>3aO@@Pk8AQbUz`jB4t5>fA{yMVhow*K! zGe6+A$H&Ke1_nIpOId;ALQd((t?ljPtgP~+q$HD69PwhJ`yL12%L4)eL?t9xU?RX- z47`EyTx*A3@@A&pjn3tD#qzI@=F7x++0SjH- z=<4cf4Tv84nUUJ|Pgtzy=x`R&^m(Qp;CE`SFCBWuy!7RZgkIH`t-RFCnHpa8c@HjBBPWHAD%F zuqWV7;5eX=^4EBC!RL~XfG7vGQodo(s=MqfeRZZW3YhfESAU5(!6^l9cFfKWplU9j zr*>u3X`xA0l;k;Bi6Qr8AU%`!o2JlR-V!3p<%SobM~`$Tp11Twrl|l?LOPWFc;UQ2 z#+tNEfQYYvHgW;C>&a2KWso*%{KN~^9VtzXYk<8_L`Cl=!Hbv zXMSkBi14L4RV;-xsAFR&-mw0`@l?BVbie-NZ;Xewh@tc1naw8Xx^;`9f`WuVR@_g< z0=!VCV2w}t3#FZEmbm@K5ETh67Tvb9w+PWu%RQeDQ=6*yp%m!jmaqh>EUX-eWv)+; z&hpm4E6oQo$~#>BN=gK6tDqF=EtISD{r20Mrjm#c)XneLIXx$77W>nRnImA{+Cr%1 zPiUX^{gAh{x~Ap3mBBhSscwT6Vcv9n4#^XUBtb)YW8JxgV;kx1mSS)ac>}ibshy;Z z0mnEE;NJEHC|>>B#I~NcX(ioNny)XIbCM3QdW8H~7rNys>U-qHSaZt(SMryQ4gB|p z#H{jN*MWrMo%(T`MNTF4`dL^^#R?b=le#rl)62x}_Ee-MDEx&Al~w$%F3wTTcxX?E z+00-j5PtZ_YQoyFXGPNTN+qEvTz_^j>L@%R;F0TWdNHJX-e-Q(zXx%!?kEILCfGof9;7*v?q89ixM<6SUg zlZoCJTgN4dHGP-R4Xzm}T~}9bTsgbe@JpMlw4TW5vaxkwO;WX~pFQHvTD!-eRn#*5 zmAtR{3gg`8Oy<0Fdt*?SvvuiG^Y)SsG3067l6&mfV(ZOP|BOP<`g~iKZcowsqIwch zpuI&?@hRf)-%!=TZ`pGRh5wr>svyu^_TgwM(5LGs&xOQ7jB7HMIxvB0wM0&d&ZLz; z8>5+)eA;j*&+q7165dw?Lz!h2ZaTz=_Ke*f+6wcJr7m8DqyhNnM2-WmogHu{MA<{X z#WoYK5E<*aI*tMB5nB0LjFy+}XS1O_-pAa0z!_`-D#_2+U}vX=b+1*B7KGL ze+h+mif$xFrIwl`i)mU#BX?J4(4qX}iiEGEE|h-%O=aqDc*ncO$Jd2q<#==x!g^+R zll7F9lhb~wR`+=`f`X`G(Hc;RU!-4p zY7G0my`aTev=-v9^qNfYP)cEsitbp#Jtcmx%$Y~I=cU-M zjBAZ$QyQ~#$l3kP9%1)06P|LI8h+UF-2ewYOL+EQ=yaNMipgTMEC98jpC1G@-?wNg z@pDoL#$vXn#jA_#O4r>By7ew@9%rBV%Ja!qYIXRVp zf>u;m_@*h--07nawrlessHeBr%*)HGL?6gSjUk}f4mkdO{?D7HB!G5-lz>7wrv`le zzpH>=0QuB?m+mu3JV1 z*pIOnmpNp9o!fklwl^xYlQy>q0oa5uikzIm?UHfO!$~u?Pd*IJ6FYK2bm8h6r@3p% zu+Yk8lta$b_C|&-ol%qI7+DTP?JRT&96AR^ph#i76{j?o$9CxjE<$-KT~SG!k^re5 zkDCG2D4(Nk$E=jDFZELqEya3?*|VD4r=YJ(e)?qeWw%b4T02`FQZu4oT1iNa{QAqr z9w|EhEhBISjeLHSM2V1!Xv!?`0Hyt#_>D8^GY3L!48m*-EA!mRyk)22-^-z{2cq#i zF>8w~2`Lw8hyS#{CK8Dar-wr4L1T1a3kyHn6Mw-PHwNdjIhWIX2Q!ftwEaFQthBL0 z=YhJ@^g$aUf&v-Oq_EJgEouUE{^i6daD`W#8}KK@#ebqp!B%soL2$EsY{Yk|2$>$J-&*70izQSew zm6=obn?Dt*Rjx69_jyR#*efO~S};X**;m|B{;ZFtcXB@X`6xmLF2ccp`t+q(?%D>< zVUoCz`aC8Ymzm>VyW6-1m)}^ioZ9^9Y{<=C?AnA)T21KDL+#9^YbbA38Pg#lW5)f% z^J(HrL%561qxv>7lwOAEy#k-CdS7e>wZxpcKAM5_YqamW-!OACU-H~N5ZJr`Z-12( z&8m-S$@$&z#1M4 zPH>YI5Y!^Y4RyoCW{_g`qN5d8rfl!LN2n#*i;q?aD(6%ilcJ&^$<0>33;z{ib$9IK2&7e2+qz~ zGocGDbzh&Qmswm4p9tB1FaI@*GvILob=EJT_N3}&vv_BJY;XCD8 zi)6T{l1ag8J1hHuM9DCbqTbgiWQT+E49CYYap%>%SU#vEAfjS6*D~$=h?T(%XHI zLjuB;Y(g%BICkN;$9D?i%PCrd-V5^pP4JU z=XXK@ZLa#uWzAQIu!S)@f>~Ht7Oh>dh;O*o`9V(d>_lCw(U8|1(I>2)lO?LjKT9u0`4EqH z!0C>Lm}ajyjHO*8fn0Qs2y~Y-4F7c7O$VeHqEBp)+L~+Z*XB&d1WCr*ks=d<-8-cA z0X^_|P0Y2~3>3Z2H|OX1nwKI$ww8GVEa!F%wZFY-{0i&-Zpp0Z^Qh)kx_#p9wxSvB z+R-%yY8loFQWSw^g+gAvx^1Kmx}MquHg|Rm*`BQUxrl?~ie5*@s&<5`*rr#;J3Jta zg@4_jzmVJKAkP-M+u^yAU!zpLxQauF3Ls#ODXAS$x7kM#9(&w8+aYIQ>IY%60Y-Tl zyZCcOo>)HS(xQs7Y_{UDkZXQF_FQC_OE<)#e7?>d2|fEMO-#?`#4k)zzw*T*lt{oL3YYT1xJOo6&=bSlN#Pw%fb=Y4B~kpKJ=jG-6Z`I6&Hyd_}T)~Vc-KT^EG zyq?<|DFEGCl*K1oK)Z>6oqd|-Z9H!ps+?DoqB3#taU)G8`LOq0P`UHmSt-8Nw=CyD zqrbncR^FX(OcgyV3Ki)dIe>i{HCcX?!HL*>VO7d=IEdbp&>vh84}e!TkzLP*j-Hy- z3J!_*P9)dFj(T#K1ojySJ>X04<67yQHNOef!lhLhD!6%1hKb5YEDdwJC%T+LDYZd8 zPO^g_TcDv2x|QW$FS_*W+epKD2?LDIiuW2`>6P((28`s`SaG1NOlF&0GL}!Lec3`< zRp{4d;Fe$V$^cex$LYq~SU=M6>#B6wYP4PK_n{Ix_O8A*XI)%0unOK>)6OtIWo1HM z9G-7tldjqi8Ky=c)FX?!H31P=)HqH4QL})!qKlnuf z!+oDdyDba4=v_uUqs2guBhL(maQMcql2Trcg)mbV1qCR-H^(ad2;>V<5~RQc-OrJb z+XIB${a{CHb9la--qS2aV&_h-%9Lq9Sk0nSSgxLEn77VPHNxM8o$^=_>l!f}a|gQy+tY%{so zxX0Ew$$1}Wk0UpGUHL<%-gJoV;&{X%WmIxnK~}r;IGJux``%3kHSE)deCY~o9)2Um z#duy2Q>Wp9m>Yvx4RJPn(m->q>)g78tf(@gd;Xo^?ehIs|6xaHs2#k;GWOP$zuJ*M zIf{pE0Ur)CdN-PMFDt3#RVxxf7N24ncDMLER*KO^%Z977r|idHtSZgn2ns5OJhnsU z<8BIVu<9A5LL@X@EHO`?dE#Uz>>V+5ZC6Ip z_gDzM9&pPraX1m$iRkn)+@ z&!&@QdVy&83NMWsb|}w@p4lvdXFuPlYd2TDe8v)a{3;(7px0u)k;1Rpz3hwcS{`k^ zZF1dYX6C*!S)=8}s@rhe#B|e1R9cfg*?7EMxN-=^94O;O$h)ExN(EzeGEpqJjB@D5%i=LdfxJ|skO57E4l|J}wbU4$%LCLWZRk18Ee82>w(05n| zCnVFy=j&kH>c}Z$p1MMC3^@1Kj>{ z@bH#jO4s~jOUsXLvKhd3v4Gkr;>4#dc8d4^_YY1S2yC86mATVv2n&kRh_;YrFG@T~cWR4`w=VS!>dKnp`8^a9XK)Jp6rq?hO!N(lEs^ z)Igu_c`D255pVcm?A<1GvP)0b%QU%u=>ihUb8z8&I0}t6)6$9nCQE>AZOZ^%3qLx_ z6L1=VO|(YV92*sz@x<#?RaI@lo4v8q7nqn5`$kok=;bvaYQi!>p0XK+EK!;gsruXx ze3RPeOhBL{AiVllD0|I;BNCWUzu?`t@W=o3(^Ec_YpCN_#eCWIR(*yx}?=FzLf z*8@8=e_cAm&&_o%J*1M*3eF=&z`jp)NM=MJBh6*rT&tU> zVd$eZ^dFBO!+!KGx`C8Wr|s08&&qTf=as$t_f5~)KB3)Ndl=cybT9y1nMPO3=wXG% Hld%5*!wRY@i{BS7RcP^j@eI<*MWWqhJDP~U*#B-|tuttr z94<5E+MOS-Ez4F=|AI-cmKUb7OEuwl+`4mv@j(IYr*+3eU>5UO?v0A@&d8e#uk8`C zVNx@Ef`0{SQhbCQ`L7H>73to|AWBdFTh#y5+>(X?{v$+_i%(0BDC=ggk^f1^_{T~G z{YNC>Gv*`` zx^Q}d)A0vdN6j`545!xyotp<^9Xh?1;uI3=PlJ+)maHJF1e#9CV~UUZL@vY4N7&T9 zw!nv0dC6+~m&o+%A9neoV_*&+BGDRB`7G8C*x8CqcjBk@uR(-+RF?0@-)oM*X{w3K zt+agU;SBF4w}UWdd2Lkc~YZ_&flw|En z*SW9b8_tm?-gA+nj-8Sxxt4fs%n6*{d*>J(=aTKgg1||6u0v8SdSMzlqWgU}I-P!? zqd}S7eutx&5~S&?DE2|+=|1MZ_SOu@Z%8gH#&%#VR~i#=N_@Is4Dq&4r4d}74F>VE zHwUM3tr!i5I^?tnRnn1!Cf^MujT)#PX76?Z#b)x&sk+-ZBJO5dzHA?{znQYjHEcDO zyD1(nb$C2wGqhZ)hmi3LLWkOa5xZCkn2Zn5W-zPG*v|iHb)$JS6dovJa$#@bjBcpX z=sF$NqG;qUjI5Tg-Oi$12v#!bKxmhYK#&Qq^zsU<|Pbl-i3}X9eAiUVKf; zz0%wA){$sT&|0WOKcx78$NaEVpNb|_R3NO^2-FwDwdvfy#9*`Nj890jABNG<3~@3F zj43s6+)l4%NLYO38jf!f@|rep;RigxyLugXu9j8@_Mridh&rJYdY$0!Yw0jcPY>PP z%~04y?Te?yMDR0Bq;$f5tej0H51KD})1)%inEq|W{}JvzQ~p>yX!6 zC~Q}+IQG3_c0wdLDc_kmBE_UoudX;bY;`nPiv9VkvU1%yD>=6Mkb%khLZT^u5M~PF zqD~*w9RzJegEsqoy5bbfMI_l`l(|M$u?2C$r^o z60Y&GzmATk*G70-?rXl(AI~Vzol{(xHxx{-=ZeX5=`|_%= z29J$@KsZLq3UtdEzZ`%ts32Mz9Hdpu*f&66-8@@Zdz@sBhiMdUp-t*|!k;v4Q-=65 zt@`D+p6sA#sNiAOLZgL?Fb6WdX;fV4c4DL8+3*CrZbDmXXJ$ykrO8jThShl~&n3ys zyisYJMW)c*+4bStW&dExRgzcAOS5Ub0a^M0d!%oFqvFJUgL|eoe$oxH`&lvQRbi;N z7cCpEp|?GGlXdPa4RUmor+7}23p%~Gp6+fD_Tvd5yeHIPPN`rB+x`@U8{HKd75B?t z@@;!pzVfu1#GSKn=gwnhlr+T;!yKpC3N$Q-gY$EB=-C<^sB}nTK%P!sA8Ec+Q?B3$ z5}>gzpVY)493s%Uwm2mbQIN!^BlUA5#Su z6Ay`OQ}zKFcYN!>aJQwDQGzi!-qU63>B5~qxWt-GUj>b_IJswt0sL;L1Z%5c0>Ir$REchudhOOSGu5VhI7NMF(G#+ zI)T47*i;+zQ{qHEKk>5>_8}bV6He2#`^WR{F`KnZx2#(Y>y2JgJYleoeAy*QwG?c1 zeWBdy9sDFg%u`bG5Xc-jWnW~yVVbg4_Y2g1d#)8z`tB9F5iE_`<+4RdNLKsj34^1QN6jsX%(V1z&2Fuj@lN~$(v+ug1cq0> z!?b)l_5G&D0iV?B0w4D;K3P4lcso)ymrZf)WP|ibhLfs?aW?quby8(*FYtYn$t-KV zJjho>x*Dss07RU#8?jO5YF^BWDS6d7=}j}g^%2km^a_fDl-1(?l1gl#a{?-zw?lPc zbV~SHVS24yB{5BGuHVcpFUCM3^s~zYhH!F);6TQq4s(t!nry(S+8*6AcY7~FPBd)v)a=QZ6l9#^k3H~WD( zF7LKZs5}-XMJul=Bjl*eo<-}AJErplMvEx(oC@9_5ThP03|+V6ihmfMOM9Q&tbW^F z;NRY_-s=lA6@h+;`PtiYw>t^ni8;}(GU3v3bPg5B_Q`zd2QyFJX`lF5v)=a5b(Pq;=QpWotFlehk0%o8-N-tj`Q| zK*4grmn!d&vSij0&|FbzciW#hIDJoTpeeKUimdJk#w^N|s8aUPXq|+xYo!SzmEy^C z(b2Pjxa!FH4jMA`t8sr{mm|ivRNAIZJPD1_VU=UE`ARlp4%nb%wUXGvhv(OA+;p$e zni*>OK9qXILZf#3xW9yUdmhbV%T`MR7q2ckP7!fFB6M?=V7=f%0;1FUnnzeYO z&+P&5dOt!Vi8IwU_M048g>??GyZX?5iuvkq&&zHo&m8r-m{_Tcy{q9CGpxp2eToh|l6xe9PZEWUO}8OB=vAz#oc#-k3WwROZ{3cnk{*XO`w?M{BfYww7kY zH;jrtg9Ci1Ve^H&D1bUz41&$16pSBzmQ>~XS(I7+HYlo^+Wbv^W+?`wYFjd>h%Ln& zf)@^m*+ud?8<^c@z5xT|kwJdz#INk`mEIBIp`RGS(bU`Fkzq%Uxn6rg?j+)lzTqxZ z2`&Tno&1NuwBaUPbX86aweLD2AY!*=-z=Pd6#XUP-B4+jUW^cIH9_v3?y=j|Vwjk_ z%P^}5Gcji>_POgI- z>qvrQvGyD=T4N;YBI~~8@%VG$kJlH9`*_~h7Kf&f(X#%c7m9}dbEBnECXR$eX{#-a#DcmnHoI!)o6ChA&$& zQlEjdE9iIBRX9CgL|WbN0IoZ(**j-0*KtsM{=pBO`A^ z)wBIJ4x(vr;>FL^>jTwcq!MW=*RO&0<)*BYw%%T_oshy0hn!@x#&~%uF^)K7Uf8+1 zTUEZj#6{%z>Y3j$LK2dTsc{!B*WePkgNrqli^=&G)3i)&5`hnDvng6Wq~U&(&)(}f zf_K7?^*Hdl$!r%CF%}Ai!t?#Ih$RZ(H@$kSxMI>G^Ww>vaTF=-vvx9jcG2q<^D5hW zQUA;g12M{_E;3@(2MIHes5 zJhJEl2Xf3q`{m#^1P14urI`{(A!m+5s|ASdyR`btN{YKz=e3hA6Q3s;@66%P{%*Su z-riJJAh2v!L6FWMng&!c9^gny{`DrcwWY1VzFCJq-fc}JaX6YaeB`y`Qva%z=edrh zvfdWz0m7_VI!H$@e;*t}a}3mmsP5Et%4lz;+EAQywK6pCv-t9qC=SwzGbP6NH(`Iu zb&W!R{j=A|DzewK!6>*sjd#pXC3zf4r7>+*cgID+Az|&qSE2IE z2gmN`M#J?h4qVT)WrG%vT*ZU&4$8#?D{3!dBKcm~Xx(BP1=LqhNVgrMd{8n)w5TG3?)UfOnof}u(1{)Ap9Cw)<33N zXEmnYE13Y?{!T0CHnXYuuX zXvM9k;5IKMra~g<@-8i8<*9MXya(9f(z%#%qDe>&pw(O!CsUd^==GI!B~f0b+2D+l zVxi*mXJydMDKlhAQ)IE=V;O3A!tsFmNy`X!qx9}dBUxn`W!)oh2p5T7hKmE#cA3lB zU(;H!&oF(2??h~D^RL_9Orh{q@QYb!Rr*~u$lPgz2i~fsboIV}DmsV6-S{q<(R_9x z)nDNuu=V!-F2Q?iLqGi5TBT-K`uge0|G5`=k*Yl3P5USUkd)dx1Hly!4;|08cYy~B zNp^Nx9q`_HSmTQ$4#}BDJ@MUPvplc2iK@K!kg{}5c7|NOZRL;D=y&2cIHuipn;eer zPnK`mXsZuo2sEPB#aq4m1b))_Do*iY-&wi&U3#Nf3H%D6${w+nDmR`VjS-Sb1j?lJT<<#k)gHmDi?Bla z84E*w4LTTaW8>B;Ke3LgnWIIV4czI(KDo1*O`4IW;w!|^a_Zzq=S1>odRX^1%n1=(fLn63|ctW@|Qft)&vuG$l>+Mmiytk z(0c(%=zlyD2E6nHf4Z*7_Q%RhXU^(AOR3 z*YS5H%i|Ko1UW#sLHl9$43~ULh3HYw4Bpmu@d0m}Y58o`WPt&9U~%>CcYLYAw?t09 z#4CqYZjLMXQp4r%y^Fc;A5P4t=cSl>G#_V+ICz6G9`bnIRqh@LuS9^WpeGEU`-kqu zW^*p^Y-BOX2vMrwYD(Q!d56}cu`^h3PgkJCu9jV-2DkHvdzze81f`Fk^RGm^&+TR? z`4KceW~YQSM?2Xwa*t_%Cg@b8)hp?@&j%VIt{NA{-0{X)Ed^Lwp`Pb2tT{(2(GJngR^8yYOg;?vab6kgXFpb`)W%kHWC7aLg_%adP+ zP4|z8qz6$iVf`P+P5!sBq@TX}-AO7q>uPJ>(LhGuztg64xYzFsftE5CxW^&qKXc&d zbtp&-l=KEa)8UZCKzF{E;bLNAT5UFCaEG~(8qLH%2Wicj#j|heJil3b%&2Mf!g5^g zS?C$Q-tpUTO0ep>kp+1>v@B%hrnZL=dp(ZOwAXJ8+;Neh&tg6-xW9O+$b>f@>FtLe zSPET2I~z)d+MQA?x{pZ|%)8?={EphwS|1ba7!m4bwE6o8Iiu(h*N>;pi{X%wrlESa z+Jr2I#D9R{10VSLtDTw&X+#QfX&SO(S* zNNlBIIF$UYv(|u8n2BOMGlJ(WUCqdyh4T)j`Q-OEZI5G1BaqgNLP^!FpZ86U2sLr# zeT({~d+w2V*%yRU<_g`eKDxll@EX(`0l|6l4Xy~x=xqywKORz3Pb3 zUfB`yV<%2u(C8lflMizB7W=da9Lo0S-{gd|$26=sWSE+oWj;#d#ovdj~0pA;O+ zcE(uMMp0`l;77TUm5p;P{%Ct`8G8tyt3n?ZlwL*uTWbt$8E1kweo0_-1sGG%cg0M{ z8QYQlmfS-q;^0X|b;M$_roX}Vo!>eG%GnJbeU%9_x`8B^=2~+%!w2u|B6)#y zycTQx{nIVROwOGS$=PZuWU#nF$BA`#xh)C1Nsv)QDDjUk$FA@_^|FCyn%T{-=ziPg zzEhhvx}A2C^(y_0hkFU7H8DrqPfy^W71(fD>Vp<(wc)_wfvL-1sAK0c;D0U3*Nigx)8l=C=^=qu~Bcw+`!7N-zLE11fU;A1ly2NBh1(k5diYhh5 z!otZ>ndBq&Twr6LRp)Ugu6$^=B9r{ha7KW1+bC5ylTV}dc%bK*`trHFDZomfDy(|E z6rR=!R&_j3oUBlQfu4=egx4%jf>RrO6qw27&zKxPkPp#gLdUGpVgw`P?cktFYnrmN@ zi?cRfKfb+@)y6DDa*A)_gdJ|0n``(eAy z{FE|NY6E-v{I1kz7~~jXk?V{ zn#!Q=#T{fj{br?M&fQD5uf3mRd(>-L&<(grrhb~)SlKpudj;w}o+UXQ9)_wPsi?^t_v*0w>jEo?!=7rAAel*wZPG=rBB6mbFyW+fZ8y zzAq(IB8;$*;(}U!6ilie5c|uwILn?|xZ)7nXbKmwR9Ycs8nGm6XCeXPsV(+m`;M-W zZ7B0e4h#L9htO#)B4*mTaBoFyRy$?5rrqn?pt#&aV1P!ROOKJ|ackW#EQdecu}#+} z+qJUbKGKA_r}1C;QBVu}wbId`m@glp3MRf zHv9N$bhDJv>Tgut*BNtv(asv@S!KA!u(@xL-lBzhuer-Y7NiV6bs7IV}9(5*utvrgE@P{T?%`4EqOkdD zFtPo8Tg9D%{B&!d*nF>z{papcJ<%a|hgOV{ev&C3;11dL+2%UOW}rn`WDS{o*P9D< z*to14@v@1oC_AC=cmY%KZS`TJV7i(NJp=!WX8PIw-YC>mdtO1Vsvq2gJ15c zz%q82_dN+Hu!ir3ISfFNf_3Pd05-~J{vrPCIXPqk?!cDd?_Vt8VR{}OR*Kx>Wsnro z9b}*bgtUGoyExv?^*BG|b;3*F2Fm+gbVRQ^S}Mnz2rN=Sd7{3iR0_8IY5C@V8MIXv z(4Dc;;h1eN-}s09!3tx;3R|F+`ms0fholSX1xNOHcpcsU}zS56Xav3kX;EM z<5ex^QjYBN^GwXvva8mQ1Al;FIhk^TmS8C^X>lao~ovZ`hr= zUgx-{Xv-h5G*VmpK&lbRRHCQwCXJn0x{f3SO9;72CL8}_LV0F38nKX2=cx>H@;XQC zqhZUg2QiZr%ap%@!|b3YC_#7v@$v{7Jv2dDNo0!tbY$kk+pAm`>Tv+BiDjKgO-X z_7}&`&nc{#le&6i3yG)Ik*(8NTb2izkz{etJSeq_)B*LbTR5jY? z+FsHO?InM?J(u1$DK~Gsst<JVO_U86dmstvk9_aU{^k6hg zQ*}Qu{--41+p^A_@=<}NNTh)O84D11*f+N?cd?jP#?&!Bt1r57%5zeMpd&I~@Q=$0 zuqqQohcO7Hg~vEZ&MuP!(7u3<>GCgtE;I7n*jHv#N}8@{6Q@~>U^)AUVSmow~v0zuAT{~ z>kDrv4PdQVjO~{1y}4~JBu)3b&Sl?z^f2Lzm}^)AEW53%j$%ktmI~*phZTx*fZ&gy zlW>O~_q~rvu9JVXXOFI3C!y1aq?)x* z_f@qT>f>JMTn&DiPSIQLruM_vTxObfaFZ_ZhEchozn2W?cUWq=Sn4HlHEdsgleP3i zGj8FP5B=z>we*b2dkcxTrc>XISSn=K>b;0*R5*-Gld=>ew@faYlwVIY$$5yj73E{1 zIhlyCNRFAEg{yjR4(7a3X-q^1+Ij}!xt|?Wad^WcTrE>yFeex>`+B;V9Nlv`IGd-? zv)N**ig}T_$+ZvlfaYw(9LZH*#)~=RPY@9hto*|N6jxM$DNbO>t2TL-MjsgP=*tOe z-Tzhc$KDkuh%R97HPh0QkUD5ywv2@G>o~vtQWQ|xtdfhX`gg(D zV~woElAT?P-9T=+he35|G*K>aRhd~}G{);0hq}pC&O_i0v*^y>0O#>@=8h|Q_h0PD zAt(uxABy*QN`eO&*cRd{WeEs1ge~U|`UvcHtT7cNmC|PtTUS@~YqQfG=A{Q=JV8mJ zqV3xcyZt_wy?EfWb;(;eT8IT6{nJRLl0O{F@rX*#r)ktR43CaK&z|k|_muRwnSjWI z<68s?;HUnO!kR`F}v zM@&4fhvOLXny+8qVRz75o_hE3gNLg14)|`@+hkaf5CGU_HMs@T-?tjUopn_OlU{sy zxjm3r{84f__J(W?Z&{qi`N~J|42m1qa9yiYGW$)%@fxX)9Wq#*%WCwiK6rumdi=S{ z`oZM7{5fjR>5+yIJ-m8v5~I<0AcUP0P(m9V!cIv=V`}kv>On9+(dgJF;4psOX!^|8 zMnE)N!-+nzf?JZ^i8VrvZH(T+wQ7*%qLS#fRE0(Elk&ye-$qns^{Qh}#bhh}aQ)}ZD!dLE>qQN>51Bc!=At)&4!F@G*co@eL!>G0s(f(_ z!r}=(67_k=0#(Chtm0o05ERP3Kw{36^=`iAuOdloG3AgYYKIxXnxE5p0-U%Q@CMRv z$g0(;E(2GknT)(RsWzltbOGTlEpS(@eMF4Q&KU;v`VX3Gvr~qmEig{|J+)z4TOvzt z7t8!5$3>lQ$9Qx+7H7+XW<}FP&%UAOD4fJ=<&mGy)B5_cXbQ1)M-x0V7uMhE!ZLg? z*cM%Q@|YhxqIs(W=PBw641_54YriiM;l+Hg=w!R$-pf(dK9Q(;zM*+y#%r$*WoJh= zCEOL?_oYtbji7-ZhZwC32vJ^-| zG4AoRa*dIV;bP!{9)X@0_WzU_h}++_S9!~Zz5Y}a-P^wX0~?8X8NC;x{md&UksSIQ zIg@r({&f3%&&4EGAO6ut8o_bA&c#2)aCUD?i&>c0em*}EJ9E1qR|y|99w!uZy&&YO z=H^2Nb!Y?GadgPQl&zk21G*Q$gx1X1g4-|y0Fx6VjpP}om*lw-F!cybd}y(-jO;v} z))zuQ@nH1PvdNBJ$tFSn!g-?;5IAMzj2#0G_Px}Au z%^*wc9f@$&r1BezqZl$C1q(QW~_9B|91xs}Wg?%q$O z**xN~I7$1PhYPqtH*DRbBc2^wtF$W5K8YK1Gg8%tg9I23|3! z2w6Bb{&FT1wmYtRvA842cN{XjTeHa_hhwj3eU07*1xC~g$2F|yVvv%$AKGTXN`l@Y zmq-N$UPw@{cma2ooLMsMs=ks_cJI%o8;xy(0%08wl3VFn?dLGzR_nF;7}@Hb%34Rz zJ%2|3xV!}N-t(VfeL*Mwa@E}r&8p#i4Q@DLdP!+dqve$G)P2RO@#4HVy#oxGjW z8(&ttUh@Yk@YFm%U@wmM-cjsdKL~YqVb%G}R+ zv-gjLGLK%M@pctv(@m`Lv6>|Oqnu8rGeyJP?Y^?uX>QI;^%!4S6qUm2G|BPiMvuJD z=CmLw&xKt2-L|(Vf-<9mf91o=Oo@M}s`yw0c{yn}p`|Qv6TfGZE{j3-oNYasCvD`m zhXPIa&>>3Ed&+P0`NTXaBr+TN*TDfs>6zr?GyYz^=E2P^px=7qJb}M^Wzj;ckJM@2 z^4MrQTo-Uqr1#|Y;pc5weJc2G^o|zp zfu`(6M#peh?Af9J+@+UJ--|Jnp8_R6c-0B0AWpK^(4&6;giyrE-=Qj&r{EBk zhn~J&tFx`PWd8Fz_X8DvEDk<}q2{=q`?_^lrr`Nw-_GtJ={}@Owx@vT z=~5^JcGxM)RnB#j=+!x&aO+(N*=O(%Jy)%6tgWR^K=`7SpNPOvkk7Jo4EX#H1KbK$ZodddUyIxO3u3Qd z1d))`;Z9Qo%F6WY{J$Zk;)MSfg_8a+bghtWGoT>-@X+4#c=3a+t*rxztW03Yz<|Q~ z#s=2Qmt{vk5v+Me6}wq%;gM7ZYgKi%PNOU1Xu1Fc5J)^+W!tPkuZGe`dVR7omcpU8 zJ6o9>jG(R+1YRQ#Y1cbaA=uErrF!*sF^GljZVPx&P*luSh5%~~UK0>}Q~#cY^_D?E z;K%Ij>^x_9BwVGkvXXp)89`&>yg(wd#!MX8Q*G}@P~ZLe|IP8beV-T5@Ck%9I1HMS zlapUxAI^1WiTeQ}V`3OMIpae^QD41!W!o5v7+QOrRL6%ki=7(zyLPARRgQ;Bi|#d= zkM`9toR(F8Sge=HlqP|yHl3o;GxMO8B<#nLyKkh^M-`Rr;_^0n0fE{HFUGINdlSE8 zCtND#EpN{>OC=SnL$`F~yPn zr@B@9JnNE6BY9o!R42`?@1)5DZ{4J;HO;;#5unE_85h7H7fZ{-Q#rxQ*!}etA>oIQ zA5HHs_t>>70A@Idcem9s?1;-W$XitS8hh~m4D+I{bYL4r2o3S7L_;tcbj*0XsdZDN z%jVCueRw>%jFilv1FH0N{>iPW?<+gcJd^T;s0*0Wz`hpApHmnH>D=jnYyW)wpmtYs z|MG@2^n(&PaRSOIb^2IL!ZV)RQ^4XpbN)!u8gW^gw-n#Y)x$t)gSs+C=AROu3mh#8 zgbTkXB`KnG^h6OVmcwZjO-<<$db!oOlf}To5@k7>ZeyK@P<(LXe>#Ci}7ndWO3 zj|4G7<0{S5&P~Bvo>HD2eHZbWcyUN%CZCVRIdjP{AHe%&)NE+js7km2!>o*~f1qw| zB=8DU;*~mg?owwK=3y~{Rw^uZ)9&9sgUyu52vxSy3o+53V9SpVaUxi>=ho8a{~q(~ z`4Sg}eFXaVBYmv{Vh%gLi2^jxf<7cM@z_k{eT4S+_WoE#y4^1>f0~s-*usoAO@z+8 zK5BDKwv2s-@aJWzDm=&ag2us6M#JuoK48c2F-^N^R5SL>qAQC*Fh{odQTi?4T_soA z)iAB@4VL9&y_-UmP(*V%m6EjRpB!9*M!YjP{Cj5RfG2(Ap>WDtJDg@xB1`pRS?_M8 zNgI7-GIh3S9ZK}hzrIZVsaZ#`30_&s=sKj`MN*^&KCZo6LT`0#AMtiNDTuaFE52J)6v7pR8@6 zjUquXSs79tWxtYj&-fKtGQHkOc{MnBJu|bzGqS@ZCg(<0TIYU~L`miAeF*b-dlnUe zFe~HMz==k=VQZ1W_Fe&0VjJjtwcmTWH%WvD^xv$>#&>?D^Hsd@&PXfOhx>Nm@Llza zZ=7m@zj8N*{NzZ=2f36~c-)EWN%@buAU=AU@)(r|Z zm$Kf}-A8*(1M~b6?nQR#iIQ7TvJrc%-G_qlAjMu0(bt#!r&XGvc(x|oohzqdFDEB6 zz3?2JP)SY@#aaOK`7L8xBoMNml7VRnPGIIU8yUL2$G!7o zkK(Si=EapL*Ts;Z+bsEb>VZ>pnrEkc-<}Y8Mt^7&gN^sD{4(fKQc2ZjV7Jl$vj%?3 zmDT+0z2I_^F&eRA@yFyZS`A)R$BUec-`{EmZ?h714toM(d1G$Be~2J@F6jW#od0xJ0BV-YOke#aOod@914_r*Db>dP8(p*RUw2ax${8>iR7`aNks@jXOl zxFc-u>XO%r1;UW#0HXSLGXODN^Pez{ zUJyV(xiYrrxfz|uzMB(8@w+~xV`L1=%X_1yuKtw)>75r6VqdubgIH>Pkpv9Z(gTP@n(KRG)WfeQEQY!3flHFHVdwZ@{o1UU~+Gxpol9fkav+ zu;B6GhLDU*XQ3ByX)kQSf|^V~L-P+L&DJ|X%ict1gvat6F4QM*=+)uV&=@WBA{JTL zLPxH3$rXXJ<{I6Y3kwVX0i`=!M6*!|3Cu!54gY`?z3Y(L|4dRMe~Hkd|Nrxta_g|2 zS$X^ZdJ_Db)Xmq%I+_k7Yj`>MpGr*ojo$yus_LoNNzo`%z14bw`T~ja=rmP)VCu2{ zUx)?VLt|!~3kwzWgLd_TF54dP`?&p|MM0nh_e+jp)|tI$HZ&yYOC%!EM@C7*a;^U) zSoO&Nz(oQ=j=0EB{eh@ohyX2yT*aPSXA{Bv+fGAt?E`wlzYw62;M@GF>-_E#eI!;Z z{q7%H0OV*lFC7#{YfMJd`qyA;>RF@tob+Lu)8px1pQu8w&?U%bviUKSaR`cpzU;c^ z-k>_a!Eab{G|Ye&{W~Gh)Bie;X=xFXB&uI{OeJSJPNK_RACaU^z)0xt4jyf+)n-L> zOCU_dF`J<`Zv-zMq1{WO1F+&@u(GmF82l%ptnf|yfa2%RpMO1V?2TASztds#LP7K{ zBU7Yy#@Vq4+mU`ZhY|8`K!_UNFt`yT=B+#CZ84?Hzozdl;6(R{5>)?{iuUb)Nku~> zfCVAWNSO9ZL9$x1_zi(1R&9{JoT_fo$1b0XPVew!co=^;yoG++@#Dsw4h9&aTU8<3 z>;}0Q1!(QjsBfIPo_^w7VPEW@Y~TDAm|>Fp^f^aKNH85c9Ro3iyp!j*PpK&Im0jq~jBZ=51kk zulKWiHgTk1I}F`~ptxx2kaySJv0Q?`DSora^@t#K6lII8-hg9XH_f$)Lb!-2S4Ta< zHi^pDBFl$w1bhD&A5g;rH@N+Vre=OVoTm6^?V7KV95k9&LWTH9_DItD3WSM>c?>NBD#QBKaf}Jb2|6MJJ;1!n2@u2i-jT!H z7~gB^^=LfxIh&4S_dh>n-+19Vbon8%#6HnMyKFz2B*d;>QEXV(9^i8Ywn1y>h_5!f z8*GdjB%B>AdJY#SVAohKsN82oa{4;XX&B23qvuMIWi)-I(iCSdyFl0jFC-HYk7ZPa zi(L^Wd%8YlQgY`lT7E1(QOz;LkvCA4B$S8syytipFhO^mmD#V75c1L?x8bU4`|`n) zE%iB3er|?MWLflHXFd(}veo zRo?caY+e<1C#JlVc&J_1u$8v7MACan4D#iM8r2h-I@)vvw5s1-L-ar12jmZoUQ46d zUm?G)a8Z59=1RfmrOm#>wFG0H`(imqCepwuetm3q>K8Gmx%kzfk^%B7OeK(#d^8(P z^tK9}i`T0!jIT&VqFiNp>^YiC%Slzd19fBUXXR&vc8{hu%I3Xxn%wqGa>B8(vDn8# zt&Q9QSKIraUlUW$`qxyqSo#4kuKA7o($E)cZ1|p+4d8Li30n^+LiN8w3KbLTa&sLq z9}Rp;gbqal*OsU~so9Mc_v}Z}?CL4%IeO6SZrfgs)3FnKNACPMB}D$lW6?pXukTT;fj)F5y!Ui3=0V#Odp$nK zWb>&dZl~TZ+J->*aTg3AI1@%v?%F*WGMg^n8(BiKs>Nn%iC&}q@p_xrLx=iGFXGR= z5J=psTxm;Bl{+MTp%}jW0&}ZtI=q}D?+1F&D-6w(op><#u*i36U&q*lnW@3wq6&Dt zF;RbAy&GDlxj^at4>H)vY)9Tznm*7Hhb8ST|RsxB+s?20( z8QGP=fM0w$;O|fWY3g7m4|q|_*8XpbcY18lU(KvWvcNl^5$z1vLfOp)n1brgMXALL)49m_(_EJ?chK8x zhOkOxefL=Ri{WXhqM|ZpHYgh8Tv)r#v476ehNF7I$6OP5_DFtV$VCBFT^Z|wuY3|l z5FSjw z1J47u57F$YnV-rdMpnFkw0t}kV`{GiZytY(wWa7YNS$7D5wlnRuv4q^0B$A{rxkm1o6sK+9OV=>!KQVh4)w$$0sq?wNXW=yLio5lBgijRv;0@_`L3O z;j^ZB`xkeva-gGB^iECkPNDkF(Qb)j<`%efs1AX=9O)}C~H2F?kV<}AwN>ExPH1i#faQ{)u0_Jr{2C~x+Dp~bay8dnX-!!Xbx23W&4TR5d z`s=Nk5{S(?cA7Ydt9s|8_xL1FI)<*vCx<9-=n%0Gliu5diLO-2x=}nX&)?b;d8_Q!xMFf^tmCW8VxS3swV_zY z{P*C7>V}I!2Y>BRQcAWlX_1%vVaYa0890uR4C!n%phHqjle20%Q#l}=&+7QUmJa3db z5g5%tv;X?5v3e}BIp05n{BEfAwha1R;1^;$6K*Q1dsj6nbh(SD;Ujx>_|bZ~e~`kJ z3i@`#^`z3=96Q+|=Mi(So)gdTVp7eAe?m;RlYa+|P>gENTJp+F@+fo%P4H%428y<3 z99-P?@39=&u*p8ZJvw>(s4|%`d9gXO^M-k4>;m4_4%L*)%fIgFAvaPh;>hQFu?s^> z^2f#4wi#0&uJPL%uq)ftoz63wrB-y!7?~vwC{p9{2j(5Ne~V|<05z$v`2CV~61tnO z`OCLw5alxAGF9K8OWPUI4FI4I|NAaL>l>r6q~)$to26InO`LcJdU00fiE+4}JvhMy zwLXSZB`8#Q~&_hbK2C1IKqdk7jZ@!`PM zYP(_dgI=%Tyd4j{w_Rgk&Gf|AkkNS;a|JdDNh7=;Dg|IvfTKhx0I+=;b{R%wm?`ON zh}{nQcp*=X4Vur918GB24R+YZN&X5-5dQtaH*R+@uA1Yj90l}6zr49fQtk1=c<@-w zC@mG})%~uXthuO}cXeH{>u1s?YawU{9%X$+t=o)Z~>D*fKhP(SPq=HKvhGt4d+VTI5-7}-qS-tYsGdtMvIB%Br`4Ee|PtX^4591 z$1cX>#FdjcoFu+D*`6rgz=VIH2jVx==ka$6!REM3!3|?P%YRR}i5AuDYNzgV_6gwQ z>}EAH-0KRJ-C($>O?V{O?A7@>jn_w`Umdo!DXF`N_$N2Z6Df`)Gt2) zI!2@L0Uh{th%oN#pDCPFwqq>DnGG*N_>+WFVMVZmnHBvHDe`)4E&6(I27kOd%Oi(f z?~bP~Xr!vo`fbbCJcA?iM&dgS45aTbj36~Tw|^ zKOmbP&mEnAJkI_(XCi|2cwdW6zWgcY2;wvaL)P0{3sni6EsOw77Md}LMmI`7&R?C& zir4Y{WNtSs3ZqjT%n4P29!$rhQ2BZ_vCTEGQ2A{HAGcGE!#uWpbNUKeWYm-$r0+LQ z$gkG-8eDHQDSUDztRq?L+P;uH6O0~G!$F|{EAE^atoCC)!inVsbiPizXePs7=rsp- zOh+*vKlWelx5*ha7D%lWW|d>q&wiBI@ZB*gy&rOlsIfH*i!K;bBUp7C}W%P*b<7T?ElOQ{@c+J zbUeEtF)57#0CXt-ekybg>o&Rbmzb;LSg5nmNnyA1gkj||BR*0B;Krf*|MrL-r!%fQ zK1F;bB?!z%VyloV9h5hIl*KWu)?o$TL$vuWK4^?uiw}nOrImqwSnX=PLkSNt9)KWt zzxS)KI^2qk0=X|=c(`iw%)7=RZglD2IAUMi+B@i(4R4$&{idLb2hvxkWzPF{&Fl2(byvH0mOjZz-MQ}dxi1Q7O|N$?=09FI z#YfH5!eYg{cX_4d<=RrcS`$5b{_z6!KHK*uv~K_X^#vAdE?o+mI%SH6y?wmheE)5> z=D(Qt7~3|?obvZU&h2T}Uxykv|W0g;N3*V3ROnRWr7o&<^0o80t;e*gVf7~HstF<~1! zBQU&73^{t{Dw%vM1*({l_$FuK$ytr*)eI9j7$+TLZ@lxosGc1de|JnnAHR>`Vh~bH z2zi(JU<*jG?)C@swHY0hSbVyInScVBd7}T4CHGog2zmCZ9pp!kwmdP=0X8#wK-M!z dv<)Kanf)#nw;#?|=gtH<&C}J-Wt~$(697g#Q)K`E literal 0 HcmV?d00001 diff --git a/documentation/documentation/static/img/project_upload_form_7.png b/documentation/documentation/static/img/project_upload_form_7.png new file mode 100644 index 0000000000000000000000000000000000000000..a0c70ee8b1c258a172da803d792ea9072893b45c GIT binary patch literal 2313 zcmbW3`9Bj39LFa;EK#mP?vkVA7|XRNL*&ec97|*7XmiXJb3M*I5h^0bgeP)Dk7GGQ z=GsJZBpM+s$0JO`uxHPo@I2qw>;3({zOUE!_4@wu`5u4R+1wQ1JI4nA00b<|P3$>w zo`W-xhvP3u?;<(jc(Abr9LTXKAd0}rB|=Q^gunvOA>l}z7r@&;0PA%n*c0dFxCyBnP zW9@xLl)|LDmeh~i>(X%f~%p%e$Y1`xVUIB#Z0IM4Hc=%+FO zH4YG&^2WyOJF3rq6!*)ysfo#Cq?DADSe@=Mcf4v;ESu4%7qzQ#w>D>f-b0m2VPq-@ zOp|;A0?J>$OwtQpQts8R(tKXF6?GKA;yuLx-V$z&%<|{4EQfNvlHK?85Y|S%oU7XT_v$t zETDP10MQAhr?y#f7NNTFX@#bzyJXWNEP zC+7F&Q!jnuughK65I^4eBV?Y*Qn_-aXFZI&UEb`xP+ndhp~nL=O%mvswG9moRZO{t zD%Cf^As#Hp7@w8=Y&n9kDb-l|71FKYjw&FVTR^H#vsmarZG7UTzr} zP;Xk=$*l}X1cSl5QSV}?3gOn#hO%AvGC7T5FcA_K27_sRg36Faq&-~Drn~yjMNixh z2}N`&W{BWPKPRbYs%M*2!T2eqa}_NA%(~j>{4=Af^Q_ojog?jRUa>;taJtj3ledhi ztny300q-XEkV+bt@QB!Coyy=>^`XV}Law{lf`|tvV9M@qvW6Xp(e^kRg1wa8dFq$c zfs#_|$y{yh)ss1D-o-Y{rV_Z&QNb>g+< zhq#2zy{N2b&+OgZZ8gNnt2^g9@+<4h4ktrMH}$aA?-8IjPck_+Z#Np6CUIF7dFbR6 zyzyO`MU2dgR${dkRh4JA+p?30tuiRYy1t=Ktkh#MQr+P<2}~SW4mZY#!X(Uio zuk7fBAdMO12+NUrd?Z#oxaC#Bw53|$ueGF9s;){0&2-^@fROdquGfGG-Lf3K{}hnx z{9_;>rX#k~Yh`7H2az(np(F@YHhJotwC&7F7>~A@U=YEy`W-RIHd(fL$F=CaL7Htp zCHz~Hy@aE@Y5MQ!K;Z(t;AA43Cn75P4g0Ol+Mil_H(W(>b&qYwda&sfu(jkXx$!5%atcux zf?zsnxr&O4W*UUbyR(;~t)ySn!&7zWlv@rCXFB6F(@hdPFN&@5K7klY`JmVE5#1<# z=ci`=#R*X@bgOJMGMu>BJ~rnO5BpFXjH9PyKxrUY+o-aS5oJUq+R?x7LBuL^@?h|G zOD$88SHli-;mn**sf4D$Wt|*|fz3g+z;{s4trJNb$-uP(xrlJM$w&# z6&u{C8`S+I)I$4vOj--tjCnj{ zhjtKACTrsqb2Pq0{sC2&o)__QuqtSb2ah8ltsCKE?B;x8o5i5(rbWT9blZ`nY_O!| zyDgw6`zM4I2C?uQ)^#I3|N1seg&BGCs3aQHYpwvA*Fu8G<_frR`HQx@xiTJ(Y@xhE z7PJ0dSG*7nU1DIPl%&ho;1ml^-qx}tYU7&SFq3cO*FjGDW?$PPv^a~q+NF^~uP@uj zo+~SeKX0mQTv=Hes9#9-_-l*??iS+bfqj;@P8Sp{5m$={w~`Yy$1l~_)TpgvCMPDs z1`v8>FoH(|g+duafCun5YZb)#ITL@HF@x~j#{l)6FF4ETm0m2W1o9+QNVclS@?R3? zFC#iJ_M)6MCF6~=RByvz37=Ol3IYXFd+b{af9Mw4`1$$Oc_>=h6P|#d)OG<=x)ZyR z?U$emPwT*>E;{NFflIXHzfosMR*hkfzbY*n@2t}|$)TF!@%Vo))Y!~SI(3o4&`8S~ zmyd+VxVX3w%u4rXC+?B3B9F{`eSNzYHc^qfW|S3X-ZAn*kc+G9=n*1RGljbNeqqxN z4$t~gEUvN&6>XM4?bKBCe=wGRD1QBa@GVwPz7IX|clJlln*y*fwK1tNz8C)wq{cxb literal 0 HcmV?d00001 diff --git a/documentation/documentation/static/img/undraw_docusaurus_mountain.svg b/documentation/documentation/static/img/undraw_docusaurus_mountain.svg new file mode 100644 index 00000000..af961c49 --- /dev/null +++ b/documentation/documentation/static/img/undraw_docusaurus_mountain.svg @@ -0,0 +1,171 @@ + + Easy to Use + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/documentation/documentation/static/img/undraw_docusaurus_react.svg b/documentation/documentation/static/img/undraw_docusaurus_react.svg new file mode 100644 index 00000000..94b5cf08 --- /dev/null +++ b/documentation/documentation/static/img/undraw_docusaurus_react.svg @@ -0,0 +1,170 @@ + + Powered by React + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/documentation/documentation/static/img/undraw_docusaurus_tree.svg b/documentation/documentation/static/img/undraw_docusaurus_tree.svg new file mode 100644 index 00000000..d9161d33 --- /dev/null +++ b/documentation/documentation/static/img/undraw_docusaurus_tree.svg @@ -0,0 +1,40 @@ + + Focus on What Matters + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/documentation/documentation/tsconfig.json b/documentation/documentation/tsconfig.json new file mode 100644 index 00000000..314eab8a --- /dev/null +++ b/documentation/documentation/tsconfig.json @@ -0,0 +1,7 @@ +{ + // This file is not used in compilation. It is here just for a nice editor experience. + "extends": "@docusaurus/tsconfig", + "compilerOptions": { + "baseUrl": "." + } +} From ccba571ada9382f4b07dc05a96787a80185cda86 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sun, 5 May 2024 10:33:26 +0200 Subject: [PATCH 320/377] When no run_tests.sh is given when creating a project, cannot submit. (#326) * trnanslation changes * runner check --- frontend/public/locales/nl/translation.json | 3 ++- frontend/src/components/ProjectForm/ProjectForm.tsx | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 94a5cbcf..0b693055 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -93,7 +93,8 @@ "noFilesPlaceholder": "Nog geen opgave bestanden geupload", "noRegexPlaceholder": "Nog geen regex toegevoegd", "unauthorized": "U heeft niet de juiste rechten om een project aan te maken voor dit vak", - "submissionError": "Er is een fout opgetreden bij het indienen van uw project, probeer het later opnieuw." + "submissionError": "Er is een fout opgetreden bij het indienen van uw project, probeer het later opnieuw.", + "clearSelected": "Deselecteer keuze" }, "projectView": { "submitNetworkError": "Er is iets mislopen bij het opslaan van uw indiening. Probeer het later opnieuw.", diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index f2273a87..52795fe3 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -150,7 +150,10 @@ export default function ProjectForm() { setErrorMessage(t("faultySubmission")); } setValidSubmission(constainsDocker); - } else { + } else if(runner === ''){ + setValidRunner(true); + } + else { setValidRunner(containsRuntest); if(!containsRuntest) { setErrorMessage(t("faultySubmission")); From 2bfccbdb95b0e9c80ce9dca9790f1cdddd74d706 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Sun, 5 May 2024 11:05:20 +0200 Subject: [PATCH 321/377] changed everything to have 1 env var name (#332) --- .../ProjectSubmissionOverview/ProjectSubmissionOverview.tsx | 2 +- .../ProjectSubmissionOverviewDatagrid.tsx | 2 +- frontend/src/pages/project/projectView/ProjectView.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx index 2e26bb07..0df85b87 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx @@ -5,7 +5,7 @@ import ProjectSubmissionsOverviewDatagrid from "./ProjectSubmissionOverviewDatag import download from 'downloadjs'; import {useTranslation} from "react-i18next"; import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; -const apiUrl = import.meta.env.VITE_API_HOST +const apiUrl = import.meta.env.VITE_APP_API_HOST; /** * @returns Overview page for submissions diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx index a617d47e..34f59ef8 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx @@ -9,7 +9,7 @@ import DownloadIcon from '@mui/icons-material/Download'; import download from "downloadjs"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; -const apiUrl = import.meta.env.VITE_API_HOST +const apiUrl = import.meta.env.VITE_APP_API_HOST; interface Submission { grading: string; diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 5d93c41a..5cbea550 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -17,7 +17,7 @@ import { Title } from "../../../components/Header/Title"; import { authenticatedFetch } from "../../../utils/authenticated-fetch"; import i18next from "i18next"; -const API_URL = import.meta.env.VITE_API_HOST; +const API_URL = import.meta.env.VITE_APP_API_HOST; interface Project { title: string; From fa6987d9a23a09031b74e381302d472f47f2fb3b Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sun, 5 May 2024 15:06:57 +0200 Subject: [PATCH 322/377] Fix #274 (#314) --- .../Courses/CourseUtilComponents.tsx | 25 +++++---- .../src/components/Courses/CourseUtils.tsx | 56 +++++++++++-------- 2 files changed, 48 insertions(+), 33 deletions(-) diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx index 58d01874..72f8610c 100644 --- a/frontend/src/components/Courses/CourseUtilComponents.tsx +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -101,9 +101,9 @@ export function SideScrollableCourses({ const [teacherNameFilter, setTeacherNameFilter] = useState( initialTeacherNameFilter ); - const [projects, setProjects] = useState<{ [courseId: string]: ProjectDetail[] }>( - {} - ); + const [projects, setProjects] = useState<{ + [courseId: string]: ProjectDetail[]; + }>({}); const debouncedHandleSearchChange = useMemo( () => @@ -172,14 +172,18 @@ export function SideScrollableCourses({ } const projectJson = await projectRes.json(); const projectData = projectJson.data; - const project: ProjectDetail = { - ...item, - deadlines: projectData.deadlines.map( + let projectDeadlines = []; + if (projectData.deadlines) { + projectDeadlines = projectData.deadlines.map( ([description, dateString]: [string, string]) => ({ description, date: new Date(dateString), }) - ), + ); + } + const project: ProjectDetail = { + ...item, + deadlines: projectDeadlines, }; return project; }); @@ -328,15 +332,16 @@ function EmptyOrNotProjects({ <> {projects.slice(0, 3).map((project) => { let timeLeft = ""; - if (project.deadlines != undefined) { + if (project.deadlines.length > 0) { const deadline = getNearestFutureDate(project.deadlines); - if(deadline !== null){ + if (deadline !== null) { const deadlineDate = deadline.date; const diffTime = Math.abs(deadlineDate.getTime() - now.getTime()); const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); const diffDays = Math.ceil(diffHours * 24); - timeLeft = diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; + timeLeft = + diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; } } return ( diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 5583db74..5e0ad844 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -76,13 +76,16 @@ export function callToApiToCreateCourse( .then((response) => response.json()) .then((data) => { //But first also make sure that teacher is in the course admins list - authenticatedFetch(`${apiHost}/courses/${getIdFromLink(data.url)}/admins`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ admin_uid: loggedInUid() }), - }); + authenticatedFetch( + `${apiHost}/courses/${getIdFromLink(data.url)}/admins`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ admin_uid: loggedInUid() }), + } + ); navigate(getIdFromLink(data.url)); // navigate to data.url }); } @@ -147,22 +150,30 @@ const dataLoaderProjects = async (courseId: string) => { throw new Response("Failed to fetch data", { status: res.status }); } const jsonResult = await res.json(); - const projects: ProjectDetail[] = jsonResult.data.map(async (item: Project) => { - const projectRes = await authenticatedFetch(item.project_id); - if (projectRes.status !== 200) { - throw new Response("Failed to fetch project data", { status: projectRes.status }); + const projects: ProjectDetail[] = jsonResult.data.map( + async (item: Project) => { + const projectRes = await authenticatedFetch(item.project_id); + if (projectRes.status !== 200) { + throw new Response("Failed to fetch project data", { + status: projectRes.status, + }); + } + const projectJson = await projectRes.json(); + const projectData = projectJson.data; + let projectDeadlines = []; + if (projectData.deadlines) { + projectDeadlines = projectData.deadlines.map((deadline: Deadline) => ({ + description: deadline.description, + date: new Date(deadline.date), + })); + } + const project: ProjectDetail = { + ...item, + deadlines: projectDeadlines, + }; + return project; } - const projectJson = await projectRes.json(); - const projectData = projectJson.data; - const project: ProjectDetail = { - ...item, - deadlines: projectData.deadlines.map((deadline: Deadline) => ({ - description: deadline.description, - date: new Date(deadline.date), - })), - }; - return project; - }); + ); return Promise.all(projects); }; @@ -184,7 +195,6 @@ export const dataLoaderCourseDetail = async ({ if (!courseId) { throw new Error("Course ID is undefined."); } - const course = await dataLoaderCourse(courseId); const projects = await dataLoaderProjects(courseId); const admins = await dataLoaderAdmins(courseId); From 48ed4a7cc22fed62e7f7724a710450eef2264e32 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sun, 5 May 2024 15:11:31 +0200 Subject: [PATCH 323/377] =?UTF-8?q?added=20clipboard=20to=20copy=20the=20j?= =?UTF-8?q?oin=20code=20and=20redirecting=20to=20course=20when=20=E2=80=A6?= =?UTF-8?q?=20(#325)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * added clipboard to copy the join code and redirecting to course when user already in course * styling --- .../Courses/CourseDetailTeacher.tsx | 108 ++++++++++-------- .../src/components/Courses/CourseUtils.tsx | 1 - frontend/src/loaders/join-code.ts | 13 ++- 3 files changed, 72 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index df712e43..24d816ee 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -14,6 +14,7 @@ import { Menu, MenuItem, Paper, + Tooltip, Typography, } from "@mui/material"; import { ChangeEvent, useCallback, useEffect, useState } from "react"; @@ -24,7 +25,6 @@ import { getIdFromLink, getNearestFutureDate, getUser, - appHost, ProjectDetail, } from "./CourseUtils"; import { @@ -39,6 +39,7 @@ import { timeDifference } from "../../utils/date-utils"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; import i18next from "i18next"; import { Me } from "../../types/me"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; interface UserUid { uid: string; @@ -179,9 +180,17 @@ export function CourseDetailTeacher(): JSX.Element { return ( <> - - - + + +

{t("projects")}: @@ -191,19 +200,12 @@ export function CourseDetailTeacher(): JSX.Element { - - + + - - - handleDeleteStudent( - navigate, - course.course_id, - selectedStudents - ) - } - > - - {t("deleteSelected")} - - - @@ -291,6 +277,20 @@ export function CourseDetailTeacher(): JSX.Element { + + + handleDeleteStudent( + navigate, + course.course_id, + selectedStudents + ) + } + > + + {t("deleteSelected")} + @@ -486,7 +486,10 @@ function JoinCodeMenu({ }; const handleCopyToClipboard = (join_code: string) => { - navigator.clipboard.writeText(`${appHost}/join-course?code=${join_code}`); + const host = window.location.host; + navigator.clipboard.writeText( + `${host}/${i18next.language}/courses/join?code=${join_code}` + ); }; const getCodes = useCallback(() => { @@ -560,6 +563,9 @@ function JoinCodeMenu({ vertical: "bottom", horizontal: "center", }} + style={{ + width: "25vw", + }} > {t("joinCodes")} @@ -568,25 +574,33 @@ function JoinCodeMenu({ elevation={0} style={{ margin: "1rem", + width: "100%", + maxHeight: "20vh", + height: "20vh", + overflowY: "auto", }} > {codes.map((code: JoinCode) => ( - handleCopyToClipboard(code.join_code)} - key={code.join_code} - > + - - - {code.expiry_time - ? timeDifference(code.expiry_time) - : t("noExpiryDate")} - - - - - {code.for_admins ? t("forAdmins") : t("forStudents")} - + + + + {code.expiry_time + ? timeDifference(code.expiry_time) + : t("noExpiryDate")} + + + {code.for_admins ? t("forAdmins") : t("forStudents")} + + + handleCopyToClipboard(code.join_code)} + > + + + + handleDeleteCode(code.join_code)}> diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 5e0ad844..756e264f 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -27,7 +27,6 @@ interface Deadline { } export const apiHost = import.meta.env.VITE_APP_API_HOST; -export const appHost = import.meta.env.VITE_APP_HOST; /** * @returns The uid of the acces token of the logged in user */ diff --git a/frontend/src/loaders/join-code.ts b/frontend/src/loaders/join-code.ts index 3e693d8c..f559723b 100644 --- a/frontend/src/loaders/join-code.ts +++ b/frontend/src/loaders/join-code.ts @@ -13,13 +13,22 @@ export async function synchronizeJoinCode() { const joinCode = queryParams.get("code"); if (joinCode) { - const response = await authenticatedFetch(new URL("/courses/join", API_URL)); + const response = await authenticatedFetch( + new URL("/courses/join", API_URL), + { + method: "POST", + body: JSON.stringify({ join_code: joinCode }), + headers: { "Content-Type": "application/json" }, + } + ); - if (response.ok) { + if (response.ok || response.status === 409) { const responseData = await response.json(); return redirect( `/${i18next.language}/courses/${responseData.data.course_id}` ); + } else { + throw new Error("Invalid join code"); } } else { throw new Error("No join code provided"); From 8dd0770574f00ff7af67ed467e0f3c95a9ee7e45 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 6 May 2024 14:59:07 +0200 Subject: [PATCH 324/377] added dutch translation of python evaluator (#333) * added dutch translation of python evaluator * siebe pr --- .../current/evaluators/python_evaluator.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md index c50653c8..4bdf1d92 100644 --- a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md +++ b/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md @@ -1,13 +1,13 @@ # Python evaluator -## General usage -This evaluator is responsible for running and executing tests on a student's Python code. +## Algemeen gebruik +Deze evaluator is verantwoordelijk voor het uitvoeren en testen van de Python-code van een student. -## Structure -When submitting the project a teacher can add a requirements manifest `req-manifest.txt`, this way only the packages in the requirements file are usable on the evaluator. +## Structuur +Bij het indienen van het project kan een leraar vereisten toevoegen via het bestand `req-manifest.txt`. Op deze manier zijn alleen de pakketten in het vereistenbestand bruikbaar op de evaluator. -When no manifest is present, students are able to install their own depedencies with a `requirements.txt` and a `dev-requirements.txt`. -Or the teacher can add a `requirements.txt` if they want to pre install dependencies that a are present for testing the project. +Wanneer er geen manifest aanwezig is, kunnen studenten hun eigen paketten installeren met een `requirements.txt` en een `dev-requirements.txt`. +Of de leraar kan een `requirements.txt` toevoegen als ze paketten vooraf willen installeren die aanwezig moeten zijn voor het testen van het project. -## Running tests -When a `run_tests.sh` is present in the project assignment files, it will be run when the student is submitting their code. -When running tests, it's important to note that the root of the student's submission will be `/submission`. +## Tests uitvoeren +Als er een `run_tests.sh` aanwezig is in de projectopdrachtbestanden, wordt dit uitgevoerd wanneer de student zijn code indient. +Bij het uitvoeren van tests is het belangrijk op te merken dat de map van de inzending van de student `/submission` zal zijn. From 6a2350faddee444ea777478dde70f39e3917bc3d Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 6 May 2024 14:59:58 +0200 Subject: [PATCH 325/377] Added readme for user guide (#330) * added readme * siebe pr --- documentation/documentation/README.md | 68 +++++++++++---------------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/documentation/documentation/README.md b/documentation/documentation/README.md index 0c6c2c27..a0daa9a7 100644 --- a/documentation/documentation/README.md +++ b/documentation/documentation/README.md @@ -1,41 +1,27 @@ -# Website - -This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. - -### Installation - -``` -$ yarn -``` - -### Local Development - -``` -$ yarn start -``` - -This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. - -### Build - -``` -$ yarn build -``` - -This command generates static content into the `build` directory and can be served using any static contents hosting service. - -### Deployment - -Using SSH: - -``` -$ USE_SSH=true yarn deploy -``` - -Not using SSH: - -``` -$ GIT_USER= yarn deploy -``` - -If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. +# UGent-3 project peristerónas user guide + +## Introduction +Project peristerónas has a lot of features, therefore a detailed user guide is available to consult. + +## Usage +### Development +If you want to develop on the site, run the following command: + ```sh + npm run start + ``` +This creates a lightweight version of the site, if you want to test a certain language version of the site you can use the command: + ```sh + npm run start -- --locale [language] + ``` + + ### Deployment + When you're ready to deploy, run the following command to run the proper version of the site: + ```sh + npm run build + ``` + + A static version will be built that you can access in the directory `build/`, you can then run the static site by using the command: + + ```sh + npm run serve + ``` From dcc3040d3c841d825432440b5a9add39716ba0c5 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 6 May 2024 19:52:20 +0200 Subject: [PATCH 326/377] Textfield performance issue (#334) * added useTransition to wrap useState * linter --- .../src/components/ProjectForm/ProjectForm.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index 52795fe3..12eb81ff 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -15,7 +15,7 @@ import { TableBody, Paper, Tooltip, IconButton, Tabs, Tab, } from "@mui/material"; -import React, {useEffect, useState} from "react"; +import React, {useEffect, useState, useTransition} from "react"; import JSZip from 'jszip'; import {useTranslation} from "react-i18next"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -55,6 +55,7 @@ export default function ProjectForm() { // all the stuff needed for submitting a project const [title, setTitle] = useState(''); + const [, setTransition] = useTransition(); const [titleError, setTitleError] = useState(false); const [description, setDescription] = useState(''); @@ -283,7 +284,11 @@ export default function ProjectForm() { label={t("projectTitle")} placeholder={t("projectTitle")} error={titleError} - onChange={event => setTitle(event.target.value)} + onChange={event => { + setTransition(() => { + setTitle(event.target.value); + }) + }} /> @@ -296,7 +301,11 @@ export default function ProjectForm() { multiline rows={4} error={descriptionError} - onChange={event => setDescription(event.target.value)} + onChange={event => { + setTransition(() => { + setDescription(event.target.value); + }) + }} /> From 79b028f01c23a32a85c3974136effa9d0bec85aa Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 9 May 2024 09:28:47 +0200 Subject: [PATCH 327/377] changed documentation location (#337) --- documentation/{documentation => }/.gitignore | 2 +- documentation/{documentation => }/README.md | 0 documentation/{documentation => }/babel.config.js | 0 .../docs/evaluators/_category_.json | 0 .../docs/evaluators/custom_evaluator.md | 0 .../docs/evaluators/general_evaluator.md | 0 .../docs/evaluators/python_evaluator.md | 0 documentation/{documentation => }/docs/intro.md | 0 .../docs/projectform/_category_.json | 0 .../{documentation => }/docs/projectform/image.png | Bin .../docs/projectform/project_upload_form.md | 0 .../{documentation => }/docusaurus.config.ts | 0 .../current/evaluators/_category_.json | 0 .../current/evaluators/custom_evaluator.md | 0 .../current/evaluators/general_evaluator.md | 0 .../current/evaluators/python_evaluator.md | 0 .../docusaurus-plugin-content-docs/current/intro.md | 0 .../current/projectform/project_upload_form.md | 0 documentation/{documentation => }/package-lock.json | 0 documentation/{documentation => }/package.json | 0 documentation/{documentation => }/sidebars.ts | 0 .../src/components/HomepageFeatures/index.tsx | 0 .../components/HomepageFeatures/styles.module.css | 0 .../{documentation => }/src/css/custom.css | 0 .../{documentation => }/src/pages/index.module.css | 0 .../{documentation => }/src/pages/index.tsx | 0 documentation/{documentation => }/static/.nojekyll | 0 .../{documentation => }/static/img/logo_app.png | Bin .../{documentation => }/static/img/logo_ugent.png | Bin .../static/img/project_form_1.png | Bin .../static/img/project_form_2.png | Bin .../static/img/project_upload_form_3.png | Bin .../static/img/project_upload_form_4.png | Bin .../static/img/project_upload_form_5.png | Bin .../static/img/project_upload_form_6.png | Bin .../static/img/project_upload_form_7.png | Bin .../static/img/undraw_docusaurus_mountain.svg | 0 .../static/img/undraw_docusaurus_react.svg | 0 .../static/img/undraw_docusaurus_tree.svg | 0 documentation/{documentation => }/tsconfig.json | 0 40 files changed, 1 insertion(+), 1 deletion(-) rename documentation/{documentation => }/.gitignore (93%) rename documentation/{documentation => }/README.md (100%) rename documentation/{documentation => }/babel.config.js (100%) rename documentation/{documentation => }/docs/evaluators/_category_.json (100%) rename documentation/{documentation => }/docs/evaluators/custom_evaluator.md (100%) rename documentation/{documentation => }/docs/evaluators/general_evaluator.md (100%) rename documentation/{documentation => }/docs/evaluators/python_evaluator.md (100%) rename documentation/{documentation => }/docs/intro.md (100%) rename documentation/{documentation => }/docs/projectform/_category_.json (100%) rename documentation/{documentation => }/docs/projectform/image.png (100%) rename documentation/{documentation => }/docs/projectform/project_upload_form.md (100%) rename documentation/{documentation => }/docusaurus.config.ts (100%) rename documentation/{documentation => }/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json (100%) rename documentation/{documentation => }/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md (100%) rename documentation/{documentation => }/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md (100%) rename documentation/{documentation => }/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md (100%) rename documentation/{documentation => }/i18n/nl/docusaurus-plugin-content-docs/current/intro.md (100%) rename documentation/{documentation => }/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md (100%) rename documentation/{documentation => }/package-lock.json (100%) rename documentation/{documentation => }/package.json (100%) rename documentation/{documentation => }/sidebars.ts (100%) rename documentation/{documentation => }/src/components/HomepageFeatures/index.tsx (100%) rename documentation/{documentation => }/src/components/HomepageFeatures/styles.module.css (100%) rename documentation/{documentation => }/src/css/custom.css (100%) rename documentation/{documentation => }/src/pages/index.module.css (100%) rename documentation/{documentation => }/src/pages/index.tsx (100%) rename documentation/{documentation => }/static/.nojekyll (100%) rename documentation/{documentation => }/static/img/logo_app.png (100%) rename documentation/{documentation => }/static/img/logo_ugent.png (100%) rename documentation/{documentation => }/static/img/project_form_1.png (100%) rename documentation/{documentation => }/static/img/project_form_2.png (100%) rename documentation/{documentation => }/static/img/project_upload_form_3.png (100%) rename documentation/{documentation => }/static/img/project_upload_form_4.png (100%) rename documentation/{documentation => }/static/img/project_upload_form_5.png (100%) rename documentation/{documentation => }/static/img/project_upload_form_6.png (100%) rename documentation/{documentation => }/static/img/project_upload_form_7.png (100%) rename documentation/{documentation => }/static/img/undraw_docusaurus_mountain.svg (100%) rename documentation/{documentation => }/static/img/undraw_docusaurus_react.svg (100%) rename documentation/{documentation => }/static/img/undraw_docusaurus_tree.svg (100%) rename documentation/{documentation => }/tsconfig.json (100%) diff --git a/documentation/documentation/.gitignore b/documentation/.gitignore similarity index 93% rename from documentation/documentation/.gitignore rename to documentation/.gitignore index b2d6de30..e6e9ac43 100644 --- a/documentation/documentation/.gitignore +++ b/documentation/.gitignore @@ -17,4 +17,4 @@ npm-debug.log* yarn-debug.log* -yarn-error.log* +yarn-error.log* \ No newline at end of file diff --git a/documentation/documentation/README.md b/documentation/README.md similarity index 100% rename from documentation/documentation/README.md rename to documentation/README.md diff --git a/documentation/documentation/babel.config.js b/documentation/babel.config.js similarity index 100% rename from documentation/documentation/babel.config.js rename to documentation/babel.config.js diff --git a/documentation/documentation/docs/evaluators/_category_.json b/documentation/docs/evaluators/_category_.json similarity index 100% rename from documentation/documentation/docs/evaluators/_category_.json rename to documentation/docs/evaluators/_category_.json diff --git a/documentation/documentation/docs/evaluators/custom_evaluator.md b/documentation/docs/evaluators/custom_evaluator.md similarity index 100% rename from documentation/documentation/docs/evaluators/custom_evaluator.md rename to documentation/docs/evaluators/custom_evaluator.md diff --git a/documentation/documentation/docs/evaluators/general_evaluator.md b/documentation/docs/evaluators/general_evaluator.md similarity index 100% rename from documentation/documentation/docs/evaluators/general_evaluator.md rename to documentation/docs/evaluators/general_evaluator.md diff --git a/documentation/documentation/docs/evaluators/python_evaluator.md b/documentation/docs/evaluators/python_evaluator.md similarity index 100% rename from documentation/documentation/docs/evaluators/python_evaluator.md rename to documentation/docs/evaluators/python_evaluator.md diff --git a/documentation/documentation/docs/intro.md b/documentation/docs/intro.md similarity index 100% rename from documentation/documentation/docs/intro.md rename to documentation/docs/intro.md diff --git a/documentation/documentation/docs/projectform/_category_.json b/documentation/docs/projectform/_category_.json similarity index 100% rename from documentation/documentation/docs/projectform/_category_.json rename to documentation/docs/projectform/_category_.json diff --git a/documentation/documentation/docs/projectform/image.png b/documentation/docs/projectform/image.png similarity index 100% rename from documentation/documentation/docs/projectform/image.png rename to documentation/docs/projectform/image.png diff --git a/documentation/documentation/docs/projectform/project_upload_form.md b/documentation/docs/projectform/project_upload_form.md similarity index 100% rename from documentation/documentation/docs/projectform/project_upload_form.md rename to documentation/docs/projectform/project_upload_form.md diff --git a/documentation/documentation/docusaurus.config.ts b/documentation/docusaurus.config.ts similarity index 100% rename from documentation/documentation/docusaurus.config.ts rename to documentation/docusaurus.config.ts diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json similarity index 100% rename from documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json rename to documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/_category_.json diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md similarity index 100% rename from documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md rename to documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md similarity index 100% rename from documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md rename to documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md similarity index 100% rename from documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md rename to documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/python_evaluator.md diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md similarity index 100% rename from documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md rename to documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md diff --git a/documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md similarity index 100% rename from documentation/documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md rename to documentation/i18n/nl/docusaurus-plugin-content-docs/current/projectform/project_upload_form.md diff --git a/documentation/documentation/package-lock.json b/documentation/package-lock.json similarity index 100% rename from documentation/documentation/package-lock.json rename to documentation/package-lock.json diff --git a/documentation/documentation/package.json b/documentation/package.json similarity index 100% rename from documentation/documentation/package.json rename to documentation/package.json diff --git a/documentation/documentation/sidebars.ts b/documentation/sidebars.ts similarity index 100% rename from documentation/documentation/sidebars.ts rename to documentation/sidebars.ts diff --git a/documentation/documentation/src/components/HomepageFeatures/index.tsx b/documentation/src/components/HomepageFeatures/index.tsx similarity index 100% rename from documentation/documentation/src/components/HomepageFeatures/index.tsx rename to documentation/src/components/HomepageFeatures/index.tsx diff --git a/documentation/documentation/src/components/HomepageFeatures/styles.module.css b/documentation/src/components/HomepageFeatures/styles.module.css similarity index 100% rename from documentation/documentation/src/components/HomepageFeatures/styles.module.css rename to documentation/src/components/HomepageFeatures/styles.module.css diff --git a/documentation/documentation/src/css/custom.css b/documentation/src/css/custom.css similarity index 100% rename from documentation/documentation/src/css/custom.css rename to documentation/src/css/custom.css diff --git a/documentation/documentation/src/pages/index.module.css b/documentation/src/pages/index.module.css similarity index 100% rename from documentation/documentation/src/pages/index.module.css rename to documentation/src/pages/index.module.css diff --git a/documentation/documentation/src/pages/index.tsx b/documentation/src/pages/index.tsx similarity index 100% rename from documentation/documentation/src/pages/index.tsx rename to documentation/src/pages/index.tsx diff --git a/documentation/documentation/static/.nojekyll b/documentation/static/.nojekyll similarity index 100% rename from documentation/documentation/static/.nojekyll rename to documentation/static/.nojekyll diff --git a/documentation/documentation/static/img/logo_app.png b/documentation/static/img/logo_app.png similarity index 100% rename from documentation/documentation/static/img/logo_app.png rename to documentation/static/img/logo_app.png diff --git a/documentation/documentation/static/img/logo_ugent.png b/documentation/static/img/logo_ugent.png similarity index 100% rename from documentation/documentation/static/img/logo_ugent.png rename to documentation/static/img/logo_ugent.png diff --git a/documentation/documentation/static/img/project_form_1.png b/documentation/static/img/project_form_1.png similarity index 100% rename from documentation/documentation/static/img/project_form_1.png rename to documentation/static/img/project_form_1.png diff --git a/documentation/documentation/static/img/project_form_2.png b/documentation/static/img/project_form_2.png similarity index 100% rename from documentation/documentation/static/img/project_form_2.png rename to documentation/static/img/project_form_2.png diff --git a/documentation/documentation/static/img/project_upload_form_3.png b/documentation/static/img/project_upload_form_3.png similarity index 100% rename from documentation/documentation/static/img/project_upload_form_3.png rename to documentation/static/img/project_upload_form_3.png diff --git a/documentation/documentation/static/img/project_upload_form_4.png b/documentation/static/img/project_upload_form_4.png similarity index 100% rename from documentation/documentation/static/img/project_upload_form_4.png rename to documentation/static/img/project_upload_form_4.png diff --git a/documentation/documentation/static/img/project_upload_form_5.png b/documentation/static/img/project_upload_form_5.png similarity index 100% rename from documentation/documentation/static/img/project_upload_form_5.png rename to documentation/static/img/project_upload_form_5.png diff --git a/documentation/documentation/static/img/project_upload_form_6.png b/documentation/static/img/project_upload_form_6.png similarity index 100% rename from documentation/documentation/static/img/project_upload_form_6.png rename to documentation/static/img/project_upload_form_6.png diff --git a/documentation/documentation/static/img/project_upload_form_7.png b/documentation/static/img/project_upload_form_7.png similarity index 100% rename from documentation/documentation/static/img/project_upload_form_7.png rename to documentation/static/img/project_upload_form_7.png diff --git a/documentation/documentation/static/img/undraw_docusaurus_mountain.svg b/documentation/static/img/undraw_docusaurus_mountain.svg similarity index 100% rename from documentation/documentation/static/img/undraw_docusaurus_mountain.svg rename to documentation/static/img/undraw_docusaurus_mountain.svg diff --git a/documentation/documentation/static/img/undraw_docusaurus_react.svg b/documentation/static/img/undraw_docusaurus_react.svg similarity index 100% rename from documentation/documentation/static/img/undraw_docusaurus_react.svg rename to documentation/static/img/undraw_docusaurus_react.svg diff --git a/documentation/documentation/static/img/undraw_docusaurus_tree.svg b/documentation/static/img/undraw_docusaurus_tree.svg similarity index 100% rename from documentation/documentation/static/img/undraw_docusaurus_tree.svg rename to documentation/static/img/undraw_docusaurus_tree.svg diff --git a/documentation/documentation/tsconfig.json b/documentation/tsconfig.json similarity index 100% rename from documentation/documentation/tsconfig.json rename to documentation/tsconfig.json From 5c5877d0bd143c279362db6e178955eb5c2a56fa Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Thu, 9 May 2024 09:29:21 +0200 Subject: [PATCH 328/377] redirect lang fix (#339) --- frontend/src/components/Courses/CourseUtilComponents.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx index 72f8610c..36e0b13e 100644 --- a/frontend/src/components/Courses/CourseUtilComponents.tsx +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -317,6 +317,8 @@ function EmptyOrNotProjects({ projects: ProjectDetail[]; noProjectsText: string; }): JSX.Element { + const { i18n } = useTranslation(); + const lang = i18n.language; if (projects === undefined || projects.length === 0) { return ( - Date: Thu, 9 May 2024 09:30:28 +0200 Subject: [PATCH 329/377] add lang path (#341) --- frontend/src/pages/project/projectView/ProjectView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 5cbea550..dca59a50 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -88,7 +88,7 @@ export default function ProjectView() { {projectData.description} {courseData && ( - + {courseData.name} )} From 4bfa40f822900da4e2547212d297f7b7c0216de7 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 9 May 2024 18:00:49 +0200 Subject: [PATCH 330/377] uses display name instead of uid (#328) * uses display name instead of uid * re added authentication * fix failing test * changed APP var * removed console log * replaced path and merge conflicts * removed 404 for submissions * fixed page spamming backend with requests * type error --------- Co-authored-by: Aron Buzogany --- .../projects/project_submissions_download.py | 3 - backend/project/endpoints/users.py | 4 + frontend/src/App.tsx | 10 +- .../ProjectSubmissionOverview.tsx | 63 +++++------ .../ProjectSubmissionOverviewDatagrid.tsx | 101 +++++++----------- .../src/loaders/submission-overview-loader.ts | 57 ++++++++++ frontend/src/types/submission.ts | 1 + 7 files changed, 142 insertions(+), 97 deletions(-) create mode 100644 frontend/src/loaders/submission-overview-loader.ts diff --git a/backend/project/endpoints/projects/project_submissions_download.py b/backend/project/endpoints/projects/project_submissions_download.py index 8e6ec83e..6ba93e93 100644 --- a/backend/project/endpoints/projects/project_submissions_download.py +++ b/backend/project/endpoints/projects/project_submissions_download.py @@ -51,9 +51,6 @@ def get_last_submissions_per_user(project_id): (Submission.submission_time == latest_submissions.c.max_time) ).all() - if not submissions: - return {"message": "No submissions found", "url": BASE_URL}, 404 - return {"message": "Resource fetched succesfully", "data": submissions}, 200 class SubmissionDownload(Resource): diff --git a/backend/project/endpoints/users.py b/backend/project/endpoints/users.py index 34e65817..cf5e054a 100644 --- a/backend/project/endpoints/users.py +++ b/backend/project/endpoints/users.py @@ -34,6 +34,10 @@ def get(self): role = Role[role.upper()] query = query.filter(userModel.role == role) + uid = request.args.getlist("uid") + if len(uid) > 0: + query = query.filter(userModel.uid.in_(uid)) + users = query.all() users = [user.to_dict() for user in users] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1402c22..c3a967a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,7 @@ import ProjectOverView from "./pages/project/projectOverview.tsx"; import { synchronizeJoinCode } from "./loaders/join-code.ts"; import { fetchMe } from "./utils/fetches/FetchMe.ts"; import {fetchProjectForm} from "./components/ProjectForm/project-form.ts"; +import loadSubmissionOverview from "./loaders/submission-overview-loader.ts"; const router = createBrowserRouter( createRoutesFromElements( @@ -34,10 +35,6 @@ const router = createBrowserRouter( } loader={fetchProjectPage} /> }> } loader={fetchProjectPage} /> - } - /> } loader={dataLoaderCourses}/> @@ -49,6 +46,11 @@ const router = createBrowserRouter( element={} loader={fetchProjectPage} /> + } + /> }> } loader={fetchProjectForm}/> diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx index 0df85b87..956d2f51 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverview.tsx @@ -1,42 +1,39 @@ -import {Box, Button, Typography} from "@mui/material"; -import {useEffect, useState} from "react"; -import {useParams} from "react-router-dom"; +import { Box, Button, Typography } from "@mui/material"; +import { useLoaderData, useParams } from "react-router-dom"; import ProjectSubmissionsOverviewDatagrid from "./ProjectSubmissionOverviewDatagrid.tsx"; -import download from 'downloadjs'; -import {useTranslation} from "react-i18next"; +import download from "downloadjs"; +import { useTranslation } from "react-i18next"; import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; -const apiUrl = import.meta.env.VITE_APP_API_HOST; +import { Project } from "../Courses/CourseUtils.tsx"; +import { Submission } from "../../types/submission.ts"; + +const APIURL = import.meta.env.VITE_APP_API_HOST; /** * @returns Overview page for submissions */ export default function ProjectSubmissionOverview() { - - const { t } = useTranslation('submissionOverview', { keyPrefix: 'submissionOverview' }); - - useEffect(() => { - fetchProject(); + const { t } = useTranslation("submissionOverview", { + keyPrefix: "submissionOverview", }); - const fetchProject = async () => { - const response = await authenticatedFetch(`${apiUrl}/projects/${projectId}`) - const jsonData = await response.json(); - setProjectTitle(jsonData["data"].title); - - } + const { projectId } = useParams<{ projectId: string }>(); + const { projectData, submissionsWithUsers } = useLoaderData() as { + projectData: Project; + submissionsWithUsers: Submission[]; + }; const downloadProjectSubmissions = async () => { - await authenticatedFetch(`${apiUrl}/projects/${projectId}/submissions-download`) - .then(res => { + await authenticatedFetch( + `${APIURL}/projects/${projectId}/submissions-download` + ) + .then((res) => { return res.blob(); }) - .then(blob => { - download(blob, 'submissions.zip'); + .then((blob) => { + download(blob, "submissions.zip"); }); - } - - const [projectTitle, setProjectTitle] = useState("") - const { projectId } = useParams<{ projectId: string }>(); + }; return ( - {projectTitle} - + + {projectData["title"]} + + - + - ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx index 34f59ef8..171c7976 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx @@ -1,25 +1,14 @@ -import {useParams} from "react-router-dom"; -import {useEffect, useState} from "react"; -import {DataGrid, GridColDef, GridRenderCellParams} from "@mui/x-data-grid"; -import {Box, IconButton} from "@mui/material"; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import { green, red } from '@mui/material/colors'; -import CancelIcon from '@mui/icons-material/Cancel'; -import DownloadIcon from '@mui/icons-material/Download'; +import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { Box, IconButton } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import { green, red } from "@mui/material/colors"; +import CancelIcon from "@mui/icons-material/Cancel"; +import DownloadIcon from "@mui/icons-material/Download"; import download from "downloadjs"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; +import { Submission } from "../../types/submission"; -const apiUrl = import.meta.env.VITE_APP_API_HOST; - - interface Submission { - grading: string; - project_id: string; - submission_id: string; - submission_path: string; - submission_status: string; - submission_time: string; - uid: string; - } +const APIURL = import.meta.env.VITE_APP_API_HOST; /** * @returns unique id for datarows @@ -29,68 +18,60 @@ function getRowId(row: Submission) { } const fetchSubmissionsFromUser = async (submission_id: string) => { - await authenticatedFetch(`${apiUrl}/submissions/${submission_id}/download`) - .then(res => { + await authenticatedFetch(`${APIURL}/submissions/${submission_id}/download`) + .then((res) => { return res.blob(); }) - .then(blob => { + .then((blob) => { download(blob, `submissions_${submission_id}.zip`); }); -} +}; const columns: GridColDef[] = [ - { field: 'submission_id', headerName: 'Submission ID', flex: 0.4 }, - { field: 'uid', headerName: 'Student ID', width: 160, flex: 0.4 }, + { field: "submission_id", headerName: "Submission ID", flex: 0.4 }, + { field: "display_name", headerName: "Student", width: 160, flex: 0.4 }, { - field: 'grading', - headerName: 'Grading', + field: "grading", + headerName: "Grading", editable: true, - flex: 0.2 + flex: 0.2, }, { - field: 'submission_status', - headerName: 'Status', + field: "submission_status", + headerName: "Status", renderCell: (params: GridRenderCellParams) => ( <> - { - params.row.submission_status === "SUCCESS" ? ( - - ) : - } + {params.row.submission_status === "SUCCESS" ? ( + + ) : ( + + )} - ) + ), }, { - field: 'submission_path', - headerName: 'Download', + field: "submission_path", + headerName: "Download", renderCell: (params: GridRenderCellParams) => ( - fetchSubmissionsFromUser(params.row.submission_id)}> + fetchSubmissionsFromUser(params.row.submission_id)} + > - ) - }]; + ), + }, +]; /** * @returns the datagrid for displaying submissiosn */ -export default function ProjectSubmissionsOverviewDatagrid() { - const { projectId } = useParams<{ projectId: string }>(); - const [submissions, setSubmissions] = useState([]) - - useEffect(() => { - fetchLastSubmissionsByUser(); - }); - - const fetchLastSubmissionsByUser = async () => { - const response = await authenticatedFetch(`${apiUrl}/projects/${projectId}/latest-per-user`) - const jsonData = await response.json(); - setSubmissions(jsonData.data); - } - +export default function ProjectSubmissionsOverviewDatagrid({ + submissions, +}: { + submissions: Submission[]; +}) { return ( - + - ) -} \ No newline at end of file + ); +} diff --git a/frontend/src/loaders/submission-overview-loader.ts b/frontend/src/loaders/submission-overview-loader.ts new file mode 100644 index 00000000..2ee5ab7c --- /dev/null +++ b/frontend/src/loaders/submission-overview-loader.ts @@ -0,0 +1,57 @@ +import { Params } from "react-router-dom"; +import { Me } from "../types/me"; +import { Submission } from "../types/submission"; +import { authenticatedFetch } from "../utils/authenticated-fetch"; + +const APIURL = import.meta.env.VITE_APP_API_HOST; + +const fetchDisplaynameByUid = async (uids: [string]) => { + const uidParams = new URLSearchParams(); + for (const uid of uids) { + uidParams.append("uid", uid); + } + const uidUrl = `${APIURL}/users?` + uidParams; + const response = await authenticatedFetch(uidUrl); + const jsonData = await response.json(); + + return jsonData.data; +}; + +/** + * + * @param param0 - projectId + * @returns - projectData and submissionsWithUsers + */ +export default async function loadSubmissionOverview({ + params, +}: { + params: Params; +}) { + const projectId = params.projectId; + const projectResponse = await authenticatedFetch( + `${APIURL}/projects/${projectId}` + ); + const projectData = (await projectResponse.json())["data"]; + + const overviewResponse = await authenticatedFetch( + `${APIURL}/projects/${projectId}/latest-per-user` + ); + const jsonData = await overviewResponse.json(); + const uids = jsonData.data.map((submission: Submission) => submission.uid); + const users = await fetchDisplaynameByUid(uids); + + const submissionsWithUsers = jsonData.data.map((submission: Submission) => { + // Find the corresponding user for this submission's UID + const user = users.find((user: Me) => user.uid === submission.uid); + // Add user information to the submission + return { + ...submission, + display_name: user.display_name, + }; + }); + + return { + projectData, + submissionsWithUsers, + }; +} diff --git a/frontend/src/types/submission.ts b/frontend/src/types/submission.ts index 4522dbac..4eab356f 100644 --- a/frontend/src/types/submission.ts +++ b/frontend/src/types/submission.ts @@ -2,4 +2,5 @@ export interface Submission { submission_id: string; submission_time: string; submission_status: string; + uid: string; } From cbd7f837b171f7730b62e627329cfc4e2e66dfcb Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Sat, 11 May 2024 17:19:14 +0200 Subject: [PATCH 331/377] Using react mui tabs in project view instead of implementing it again (#343) * Fix #279 * linting --- .../project/projectView/SubmissionCard.tsx | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx index d9d8a898..afd9faa5 100644 --- a/frontend/src/pages/project/projectView/SubmissionCard.tsx +++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx @@ -1,12 +1,13 @@ import { Alert, - Button, Card, CardContent, CardHeader, Grid, IconButton, LinearProgress, + Tab, + Tabs, Typography, } from "@mui/material"; import SendIcon from "@mui/icons-material/Send"; @@ -25,7 +26,7 @@ interface SubmissionCardProps { } /** - * + * * @param params - regexRequirements, submissionUrl, projectId * @returns - SubmissionCard component which allows the user to submit files * and view previous submissions @@ -35,26 +36,29 @@ export default function SubmissionCard({ submissionUrl, projectId, }: SubmissionCardProps) { - const { t } = useTranslation('translation', { keyPrefix: 'projectView' }); + const { t } = useTranslation("translation", { keyPrefix: "projectView" }); const [activeTab, setActiveTab] = useState("submit"); const [selectedFile, setSelectedFile] = useState(null); const [uploadProgress, setUploadProgress] = useState(null); const [errorMessage, setErrorMessage] = useState(null); - const [previousSubmissions, setPreviousSubmissions] = useState([]); + const [previousSubmissions, setPreviousSubmissions] = useState( + [] + ); const handleFileDrop = (file: File) => { setSelectedFile(file); }; useEffect(() => { - - authenticatedFetch(`${submissionUrl}?project_id=${projectId}`).then((response) => { - if (response.ok) { - response.json().then((data) => { - setPreviousSubmissions(data["data"]); - }); + authenticatedFetch(`${submissionUrl}?project_id=${projectId}`).then( + (response) => { + if (response.ok) { + response.json().then((data) => { + setPreviousSubmissions(data["data"]); + }); + } } - }) + ); }, [projectId, submissionUrl]); const handleSubmit = async () => { @@ -99,12 +103,15 @@ export default function SubmissionCard({ - - - + { + setActiveTab(newValue); + }} + > + + + } /> @@ -141,7 +148,10 @@ export default function SubmissionCard({ ) : ( - + )} From 1d0b4f8b50213ac1ad35ac6d8d3151226134e6ec Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sun, 12 May 2024 16:42:56 +0200 Subject: [PATCH 332/377] Fix flickering with data loader (#348) * added loader * added loader * linter * fix name --- .../components/Courses/AllCoursesTeacher.tsx | 11 +++- .../Courses/CourseUtilComponents.tsx | 58 +------------------ .../src/components/Courses/CourseUtils.tsx | 52 ++++++++++++++++- 3 files changed, 61 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/Courses/AllCoursesTeacher.tsx b/frontend/src/components/Courses/AllCoursesTeacher.tsx index facfa9c2..12b736a8 100644 --- a/frontend/src/components/Courses/AllCoursesTeacher.tsx +++ b/frontend/src/components/Courses/AllCoursesTeacher.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { SideScrollableCourses } from "./CourseUtilComponents"; -import { Course, callToApiToCreateCourse } from "./CourseUtils"; +import {Course, callToApiToCreateCourse, ProjectDetail} from "./CourseUtils"; import { Title } from "../Header/Title"; import { useLoaderData } from "react-router-dom"; @@ -12,7 +12,12 @@ import { useLoaderData } from "react-router-dom"; */ export function AllCoursesTeacher(): JSX.Element { const [open, setOpen] = useState(false); - const courses = (useLoaderData() as Course[]); + const loader = useLoaderData() as { + courses: Course[]; + projects: { [courseId: string]: ProjectDetail[] } + }; + const courses = loader.courses; + const projects = loader.projects; const [courseName, setCourseName] = useState(''); const [error, setError] = useState(''); @@ -49,7 +54,7 @@ export function AllCoursesTeacher(): JSX.Element { <> - + {t('courseForm')}
diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx index 36e0b13e..08495726 100644 --- a/frontend/src/components/Courses/CourseUtilComponents.tsx +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -12,9 +12,7 @@ import { } from "@mui/material"; import { Course, - Project, ProjectDetail, - apiHost, getIdFromLink, getNearestFutureDate, } from "./CourseUtils"; @@ -22,7 +20,6 @@ import { Link, useNavigate, useLocation } from "react-router-dom"; import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import debounce from "debounce"; -import { authenticatedFetch } from "../../utils/authenticated-fetch"; /** * @param text - The text to be displayed @@ -79,9 +76,10 @@ export function SearchBox({ * @returns A component to display courses in horizontal scroller where each course is a card containing its name. */ export function SideScrollableCourses({ - courses, + courses,projects }: { courses: Course[]; + projects: {[courseId: string]: ProjectDetail[];}; }): JSX.Element { //const navigate = useNavigate(); const location = useLocation(); @@ -101,9 +99,6 @@ export function SideScrollableCourses({ const [teacherNameFilter, setTeacherNameFilter] = useState( initialTeacherNameFilter ); - const [projects, setProjects] = useState<{ - [courseId: string]: ProjectDetail[]; - }>({}); const debouncedHandleSearchChange = useMemo( () => @@ -150,55 +145,6 @@ export function SideScrollableCourses({ setTeacherNameFilter(newTeacherNameFilter); }; - useEffect(() => { - // Fetch projects for each course - const fetchProjects = async () => { - const projectPromises = courses.map((course) => - authenticatedFetch( - `${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}` - ).then((response) => response.json()) - ); - - const projectResults = await Promise.all(projectPromises); - const projectsMap: { [courseId: string]: ProjectDetail[] } = {}; - - projectResults.forEach((result, index) => { - const detailProjectPromises = result.data.map(async (item: Project) => { - const projectRes = await authenticatedFetch(item.project_id); - if (projectRes.status !== 200) { - throw new Response("Failed to fetch project data", { - status: projectRes.status, - }); - } - const projectJson = await projectRes.json(); - const projectData = projectJson.data; - let projectDeadlines = []; - if (projectData.deadlines) { - projectDeadlines = projectData.deadlines.map( - ([description, dateString]: [string, string]) => ({ - description, - date: new Date(dateString), - }) - ); - } - const project: ProjectDetail = { - ...item, - deadlines: projectDeadlines, - }; - return project; - }); - Promise.all(detailProjectPromises).then((projects) => { - projectsMap[getIdFromLink(courses[index].course_id)] = projects; - setProjects({ ...projectsMap }); - }); - }); - - setProjects(projectsMap); - }; - - fetchProjects(); - }, [courses]); - const filteredCourses = courses.filter( (course) => course.name.toLowerCase().includes(searchTerm.toLowerCase()) && diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 756e264f..908ad41b 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -133,9 +133,59 @@ const fetchData = async (url: string, params?: URLSearchParams) => { export const dataLoaderCourses = async () => { //const params = new URLSearchParams({ 'teacher': loggedInUid() }); - return fetchData(`courses`); + + const courses = await fetchData(`courses`); + const projects = await fetchProjectsCourse(courses); + for( const c of courses){ + const teacher = await fetchData(`users/${c.teacher}`) + c.teacher = teacher.display_name + } + return {courses, projects} }; +/** + * Fetch the projects for the Course component + * @param courses - All the courses + * @returns the projects + */ +export async function fetchProjectsCourse (courses:Course[]) { + const projectPromises = courses.map((course) => + authenticatedFetch( + `${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}` + ).then((response) => response.json()) + ); + + const projectResults = await Promise.all(projectPromises); + const projectsMap: { [courseId: string]: ProjectDetail[] } = {}; + for await (const [index, result] of projectResults.entries()) { + projectsMap[getIdFromLink(courses[index].course_id)] = await Promise.all(result.data.map(async (item: Project) => { + const projectRes = await authenticatedFetch(item.project_id); + if (projectRes.status !== 200) { + throw new Response("Failed to fetch project data", { + status: projectRes.status, + }); + } + const projectJson = await projectRes.json(); + const projectData = projectJson.data; + let projectDeadlines = []; + if (projectData.deadlines) { + projectDeadlines = projectData.deadlines.map( + ([description, dateString]: [string, string]) => ({ + description, + date: new Date(dateString), + }) + ); + } + const project: ProjectDetail = { + ...item, + deadlines: projectDeadlines, + }; + return project; + })); + } + return { ...projectsMap }; +} + const dataLoaderCourse = async (courseId: string) => { return fetchData(`courses/${courseId}`); }; From 993154e2971a8f217f8ea231cc8565355af9f271 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Wed, 15 May 2024 20:53:25 +0200 Subject: [PATCH 333/377] Seeder should generate unique submission times now (#354) * unique deadlines * unique submiss times * max 10 days into past --- backend/seeder/seeder.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/seeder/seeder.py b/backend/seeder/seeder.py index 20b320c0..005cb871 100644 --- a/backend/seeder/seeder.py +++ b/backend/seeder/seeder.py @@ -127,10 +127,15 @@ def generate_submissions(project_id, student_uid): statusses = [SubmissionStatus.SUCCESS, SubmissionStatus.FAIL, SubmissionStatus.LATE, SubmissionStatus.RUNNING] num_submissions = random.randint(0, 2) + submission_times = [] for _ in range(num_submissions): + past_datetime = datetime.now() - timedelta(days=random.randint(0, 10)) + while past_datetime in submission_times: + past_datetime = datetime.now() - timedelta(days=random.randint(0, 10)) + submission_times.append(past_datetime) submission = Submission(project_id=project_id, uid=student_uid, - submission_time=datetime.now(), + submission_time=past_datetime, submission_path="", submission_status=random.choice(statusses)) graded = random.choice([True, False]) From 5e0c6e47e16226dabccb59e97918ef96545d2a6b Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Thu, 16 May 2024 09:02:18 +0200 Subject: [PATCH 334/377] Project form/drag drop translations (#359) * added some translations * consistency! --- frontend/public/locales/en/projectformTranslation.json | 6 ++++++ frontend/public/locales/nl/projectformTranslation.json | 6 ++++++ frontend/src/components/FolderUpload/FolderUpload.tsx | 7 +++++-- frontend/src/components/ProjectForm/FileStructureForm.tsx | 8 ++++---- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/frontend/public/locales/en/projectformTranslation.json b/frontend/public/locales/en/projectformTranslation.json index 159e0d94..bdaa2dcf 100644 --- a/frontend/public/locales/en/projectformTranslation.json +++ b/frontend/public/locales/en/projectformTranslation.json @@ -4,6 +4,8 @@ "startsWith": "Filename starts with", "endsWith": "Filename ends with", "contains": "Filename contains string", + "addRestriction": "Add file restriction", + "fileExtension": "File extension", "helperRegexText": "Regex can't be empty or already added" }, "advancedRegex": { @@ -17,5 +19,9 @@ "clearSelected": "Clear Selection", "tooltipRunner": "If you're having trouble figuring out the runner please refer to the docs", "userDocs": "runner user docs" + }, + "dragAndDrop": { + "dragDropHere" :"Drag & Drop a File Here", + "orSelectFile": "Or click to select a file" } } \ No newline at end of file diff --git a/frontend/public/locales/nl/projectformTranslation.json b/frontend/public/locales/nl/projectformTranslation.json index 9835abc8..6445ca58 100644 --- a/frontend/public/locales/nl/projectformTranslation.json +++ b/frontend/public/locales/nl/projectformTranslation.json @@ -4,6 +4,8 @@ "startsWith": "Bestandnaam start met", "endsWith": "Bestandnaam eindigt met", "contains": "Bestandnaam bevat", + "addRestriction": "Voeg een bestand restrictie toe", + "fileExtension": "Bestandsextensie", "helperRegexText": "De gegeven regex mag niet leeg of al toegevoegd zijn" }, "advancedRegex": { @@ -17,5 +19,9 @@ "clearSelected": "Deselecteer keuze", "tooltipRunner": "Als je moeilijkheden ondervindt om de runner te gebruiken, gebruik volgende documentatie", "userDocs": "runner user docs" + }, + "dragAndDrop": { + "dragDropHere" :"Sleep & Los een Bestand Hier", + "orSelectFile": "Of klik om een bestand te selecteren" } } \ No newline at end of file diff --git a/frontend/src/components/FolderUpload/FolderUpload.tsx b/frontend/src/components/FolderUpload/FolderUpload.tsx index 8a94de78..36a71258 100644 --- a/frontend/src/components/FolderUpload/FolderUpload.tsx +++ b/frontend/src/components/FolderUpload/FolderUpload.tsx @@ -2,6 +2,7 @@ import { Button, Grid, Paper, Typography, styled } from "@mui/material"; import { verifyZipContents, getFileExtension } from "../../utils/file-utils"; import JSZip from "jszip"; import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; interface FolderDragDropProps { onFileDrop?: (file: File) => void; @@ -16,6 +17,8 @@ const FolderDragDrop: React.FC = ({ regexRequirements, onWrongInput, }) => { + const { t } = useTranslation('projectformTranslation', { keyPrefix: 'dragAndDrop' }); + const [isDraggingOver, setIsDraggingOver] = useState(false); const VisuallyHiddenInput = styled("input")({ @@ -113,13 +116,13 @@ const FolderDragDrop: React.FC = ({ textAlign: "center" }} > - Drag & Drop a File Here + {t('dragDropHere')} diff --git a/frontend/src/components/ProjectForm/FileStructureForm.tsx b/frontend/src/components/ProjectForm/FileStructureForm.tsx index 4f528631..2acbe783 100644 --- a/frontend/src/components/ProjectForm/FileStructureForm.tsx +++ b/frontend/src/components/ProjectForm/FileStructureForm.tsx @@ -77,8 +77,8 @@ export default function FileStuctureForm({ handleSubmit, regexError } : Props) { setContains(e.target.value)} @@ -88,11 +88,11 @@ export default function FileStuctureForm({ handleSubmit, regexError } : Props) { freeSolo value={extension} onChange={(_event, value) => handleExtensionChange(value)} - renderInput={(params) => } + renderInput={(params) => } options={extensions.map((t) => t)} /> ) From 561a1f3380070aa3a0dcc96c32b5775983b07a32 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 16 May 2024 15:44:29 +0200 Subject: [PATCH 335/377] fixed axios.post to use authentication (#363) --- frontend/src/pages/project/projectView/SubmissionCard.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx index afd9faa5..84365978 100644 --- a/frontend/src/pages/project/projectView/SubmissionCard.tsx +++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx @@ -18,6 +18,7 @@ import { useTranslation } from "react-i18next"; import SubmissionsGrid from "./SubmissionsGrid"; import { Submission } from "../../../types/submission"; import { authenticatedFetch } from "../../../utils/authenticated-fetch"; +import { getCSRFCookie } from "../../../utils/csrf"; interface SubmissionCardProps { regexRequirements?: string[]; @@ -72,9 +73,10 @@ export default function SubmissionCard({ form.append("uid", "teacher"); try { const response = await axios.post(submissionUrl, form, { + withCredentials: true, headers: { "Content-Type": "multipart/form-data", - Authorization: "teacher", + "X-CSRF-TOKEN": getCSRFCookie() }, onUploadProgress: (progressEvent) => { if (progressEvent.total) { From cf2ce81423940fe260335d336c20c61c3043fb94 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Thu, 16 May 2024 16:27:21 +0200 Subject: [PATCH 336/377] del (#365) --- frontend/src/pages/project/projectView/SubmissionCard.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/project/projectView/SubmissionCard.tsx b/frontend/src/pages/project/projectView/SubmissionCard.tsx index 84365978..72c599f8 100644 --- a/frontend/src/pages/project/projectView/SubmissionCard.tsx +++ b/frontend/src/pages/project/projectView/SubmissionCard.tsx @@ -70,7 +70,6 @@ export default function SubmissionCard({ } form.append("files", selectedFile); form.append("project_id", projectId); - form.append("uid", "teacher"); try { const response = await axios.post(submissionUrl, form, { withCredentials: true, From ce15d4103ab01868f22ffb827ad41b9d3cddc846 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Thu, 16 May 2024 17:11:35 +0200 Subject: [PATCH 337/377] outdated code (#358) Co-authored-by: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> --- frontend/src/components/Courses/AllCoursesTeacher.tsx | 2 +- frontend/src/components/Courses/CourseUtils.tsx | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/frontend/src/components/Courses/AllCoursesTeacher.tsx b/frontend/src/components/Courses/AllCoursesTeacher.tsx index 12b736a8..a0e15145 100644 --- a/frontend/src/components/Courses/AllCoursesTeacher.tsx +++ b/frontend/src/components/Courses/AllCoursesTeacher.tsx @@ -14,7 +14,7 @@ export function AllCoursesTeacher(): JSX.Element { const [open, setOpen] = useState(false); const loader = useLoaderData() as { courses: Course[]; - projects: { [courseId: string]: ProjectDetail[] } + projects: { [courseId: string]: ProjectDetail[] }; }; const courses = loader.courses; const projects = loader.projects; diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 908ad41b..9b6610a5 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -74,17 +74,6 @@ export function callToApiToCreateCourse( }) .then((response) => response.json()) .then((data) => { - //But first also make sure that teacher is in the course admins list - authenticatedFetch( - `${apiHost}/courses/${getIdFromLink(data.url)}/admins`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ admin_uid: loggedInUid() }), - } - ); navigate(getIdFromLink(data.url)); // navigate to data.url }); } From c73d4a127e244bf579a962ce99311b68a731c74d Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sat, 18 May 2024 10:35:09 +0200 Subject: [PATCH 338/377] teacher added as student to course for real this time (#368) --- backend/seeder/seeder.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/seeder/seeder.py b/backend/seeder/seeder.py index 005cb871..a85be82d 100644 --- a/backend/seeder/seeder.py +++ b/backend/seeder/seeder.py @@ -177,6 +177,8 @@ def into_the_db(my_uid): course_id = insert_course_into_db_get_id(session, teacher_uid) subscribed_students = populate_course_students( session, course_id, students) + session.add(CourseStudent(course_id=course_id, uid=my_uid)) + session.commit() subscribed_students.append(my_uid) # my_uid is also a student populate_course_projects( session, course_id, subscribed_students, teacher_uid) From 9ac276354f73aa7ef237968e546e1106cc55bdef Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Sat, 18 May 2024 10:45:45 +0200 Subject: [PATCH 339/377] Submission tests (#362) * Cleanup of authentication tests * Adding tests where no csrf token is given * Adding authentication tests * Adding authorization tests * Fixing most of the issues rn, but waiting on completion of other issue * fix * Fixing linter * Fixing auth tests * All tests written, but fixes needed * Fixing most tests * Fixing all tests except for the evaluator * Trying to fix tests * Fixing * linter * fix --- backend/project/endpoints/courses/courses.py | 9 +- .../projects/project_assignment_file.py | 2 +- .../endpoints/submissions/submissions.py | 4 +- backend/pylintrc | 1 + backend/tests.yaml | 2 +- backend/tests/endpoints/conftest.py | 108 ++++-- .../tests/endpoints/course/courses_test.py | 4 +- backend/tests/endpoints/endpoint.py | 18 +- backend/tests/endpoints/submissions_test.py | 313 +++++++++++------- 9 files changed, 288 insertions(+), 173 deletions(-) diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index 04b645ed..6d4409b6 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -7,6 +7,7 @@ from os import getenv from urllib.parse import urljoin +from dataclasses import fields from dotenv import load_dotenv from flask import request @@ -38,9 +39,15 @@ def get(self, uid=None): """ try: - filter_params = request.args.to_dict() + invalid_params = set(filter_params.keys()) - {f.name for f in fields(Course)} + if invalid_params: + return { + "url": RESPONSE_URL, + "message": f"Invalid query parameters {invalid_params}" + }, 400 + # Start with a base query base_query = select(Course) diff --git a/backend/project/endpoints/projects/project_assignment_file.py b/backend/project/endpoints/projects/project_assignment_file.py index 4cbd5e43..977950f5 100644 --- a/backend/project/endpoints/projects/project_assignment_file.py +++ b/backend/project/endpoints/projects/project_assignment_file.py @@ -13,7 +13,7 @@ API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") -UPLOAD_FOLDER = os.getenv('UPLOAD_URL') +UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER') ASSIGNMENT_FILE_NAME = "assignment.md" diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index b80319ab..4e72b1ff 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -87,7 +87,7 @@ def get(self, uid=None) -> dict[str, any]: # Return the submissions data["message"] = "Successfully fetched the submissions" data["data"] = [{ - "submission_id": urljoin(BASE_URL, str(s.submission_id)), + "submission_id": urljoin(f"{API_HOST}/", f"/submissions/{s.submission_id}"), "uid": urljoin(f"{API_HOST}/", f"users/{s.uid}"), "project_id": urljoin(f"{API_HOST}/", f"projects/{s.project_id}"), "grading": s.grading, @@ -188,7 +188,7 @@ def post(self, uid=None) -> dict[str, any]: data["message"] = "Successfully fetched the submissions" data["url"] = urljoin(f"{API_HOST}/", f"/submissions/{submission.submission_id}") data["data"] = submission_response(submission, API_HOST) - return data, 200 + return data, 201 except exc.SQLAlchemyError: session.rollback() diff --git a/backend/pylintrc b/backend/pylintrc index 2a73b4f9..ea890a85 100644 --- a/backend/pylintrc +++ b/backend/pylintrc @@ -12,6 +12,7 @@ disable= W0613, # Unused argument (pytest uses it) W0621, # Redefining name %r from outer scope (line %s) R0904, # Too many public methods (too many unit tests essentially) + R0913, # Too many arguments (too many fixtures essentially) [modules:project/modules/*] disable= diff --git a/backend/tests.yaml b/backend/tests.yaml index 72e329b4..fcba7cf4 100644 --- a/backend/tests.yaml +++ b/backend/tests.yaml @@ -45,7 +45,7 @@ services: TEST_AUTHENTICATION_URL: http://auth-server:5001 # Use the service name defined in Docker Compose AUTH_METHOD: test JWT_SECRET_KEY: Test123 - UPLOAD_URL: /data/assignments + UPLOAD_FOLDER: /data/assignments DOCS_JSON_PATH: static/OpenAPI_Object.yaml DOCS_URL: /docs volumes: diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index de82cf72..3f7717d7 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -15,28 +15,31 @@ from project.models.course_relation import CourseStudent, CourseAdmin from project.models.course_share_code import CourseShareCode from project.models.submission import Submission, SubmissionStatus -from project.models.project import Project +from project.models.project import Project, Runner ### AUTHENTICATION & AUTHORIZATION ### @fixture -def data_map(course: Course) -> dict[str, Any]: +def data_map(course: Course, project: Project, submission: Submission) -> dict[str, Any]: """Map an id to data""" return { - "@course_id": course.course_id + "@course_id": course.course_id, + "@project_id": project.project_id, + "@submission_id": submission.submission_id } @fixture def auth_test( request: FixtureRequest, client: FlaskClient, data_map: dict[str, Any] - ) -> tuple[str, Any, str, bool]: + ) -> tuple[str, Any, str, bool, dict[str, Any]]: """Add concrete test data to auth""" endpoint, method, token, allowed = request.param - for k, v in data_map.items(): - endpoint = endpoint.replace(k, str(v)) + for key, value in data_map.items(): + endpoint = endpoint.replace(key, str(value)) csrf = get_csrf_from_login(client, token) if token else None + data = {k.strip("@"):v for k, v in data_map.items()} - return endpoint, getattr(client, method), csrf, allowed + return endpoint, getattr(client, method), csrf, allowed, data @@ -122,6 +125,68 @@ def course(session: Session, student: User, teacher: User, admin: User) -> Cours return course +### PROJECTS ### +@fixture +def project(session: Session, course: Course): + """Return a project entry""" + project = Project( + title="Test project", + description="Test project", + deadlines=[{"deadline":"2024-05-23T21:59:59", "description":"Final deadline"}], + course_id=course.course_id, + visible_for_students=True, + archived=False, + runner=Runner.GENERAL, + regex_expressions=[".*.pdf"] + ) + session.add(project) + session.commit() + return project + + + +### SUBMISSIONS ### +@fixture +def submission(session: Session, student: User, project: Project): + """Return a submission entry""" + submission = Submission( + uid=student.uid, + project_id=project.project_id, + submission_time=datetime(2024,5,23,22,00,00,tzinfo=ZoneInfo("GMT")), + submission_path="/1", + submission_status= SubmissionStatus.SUCCESS + ) + session.add(submission) + session.commit() + return submission + +### FILES ### +@fixture +def file_empty(): + """Return an empty file""" + descriptor, name = tempfile.mkstemp() + with open(descriptor, "rb") as temp: + yield temp, name + +@fixture +def file_no_name(): + """Return a file with no name""" + descriptor, name = tempfile.mkstemp() + with open(descriptor, "w", encoding="UTF-8") as temp: + temp.write("This is a test file.") + with open(name, "rb") as temp: + yield temp, "" + +@fixture +def files(): + """Return a temporary file""" + name = "/tmp/test.pdf" + with open(name, "w", encoding="UTF-8") as file: + file.write("This is a test file.") + with open(name, "rb") as file: + yield [(file, name)] + + ### OTHER ### @pytest.fixture @@ -193,35 +258,6 @@ def valid_user_entries(session): return users -@pytest.fixture -def file_empty(): - """Return an empty file""" - descriptor, name = tempfile.mkstemp() - with open(descriptor, "rb") as temp: - yield temp, name - -@pytest.fixture -def file_no_name(): - """Return a file with no name""" - descriptor, name = tempfile.mkstemp() - with open(descriptor, "w", encoding="UTF-8") as temp: - temp.write("This is a test file.") - with open(name, "rb") as temp: - yield temp, "" - -@pytest.fixture -def files(): - """Return a temporary file""" - descriptor01, name01 = tempfile.mkstemp() - with open(descriptor01, "w", encoding="UTF-8") as temp: - temp.write("This is a test file.") - descriptor02, name02 = tempfile.mkstemp() - with open(descriptor02, "w", encoding="UTF-8") as temp: - temp.write("This is a test file.") - with open(name01, "rb") as temp01: - with open(name02, "rb") as temp02: - yield [(temp01, name01), (temp02, name02)] - @pytest.fixture def course_teacher_ad(): """A user that's a teacher for testing""" diff --git a/backend/tests/endpoints/course/courses_test.py b/backend/tests/endpoints/course/courses_test.py index 99ee348e..f2e20012 100644 --- a/backend/tests/endpoints/course/courses_test.py +++ b/backend/tests/endpoints/course/courses_test.py @@ -27,7 +27,7 @@ class TestCourseEndpoint(TestEndpoint): authentication_tests("/courses/@course_id/admins", ["get", "post", "delete"]) @mark.parametrize("auth_test", authentication_tests, indirect=True) - def test_authentication(self, auth_test: tuple[str, Any, str, bool]): + def test_authentication(self, auth_test: tuple[str, Any, str, bool, dict[str, Any]]): """Test the authentication""" super().authentication(auth_test) @@ -68,7 +68,7 @@ def test_authentication(self, auth_test: tuple[str, Any, str, bool]): ["student", "student_other", "teacher_other", "admin", "admin_other"]) @mark.parametrize("auth_test", authorization_tests, indirect=True) - def test_authorization(self, auth_test: tuple[str, Any, str, bool]): + def test_authorization(self, auth_test: tuple[str, Any, str, bool, dict[str, Any]]): """Test the authorization""" super().authorization(auth_test) diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index c3fb5928..2d686f0d 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -69,7 +69,7 @@ def query_parameter_tests( new_endpoint = endpoint + "?parameter=0" tests.append(param( (new_endpoint, method, token, True), - id = f"{new_endpoint} {method.upper()} {token} (parameter 0 500)" + id = f"{new_endpoint} {method.upper()} {token} (parameter 0 400)" )) for parameter in parameters: @@ -84,23 +84,23 @@ def query_parameter_tests( class TestEndpoint: """Base class for endpoint tests""" - def authentication(self, auth_test: tuple[str, Any, str, bool]): + def authentication(self, auth_test: tuple[str, Any, str, bool, dict[str, Any]]): """Test if the authentication for the given endpoint works""" - endpoint, method, csrf, allowed = auth_test + endpoint, method, csrf, allowed, data = auth_test if csrf: - response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) + response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}, data = data) else: - response = method(endpoint) + response = method(endpoint, json = data) assert allowed == (response.status_code != 401) - def authorization(self, auth_test: tuple[str, Any, str, bool]): + def authorization(self, auth_test: tuple[str, Any, str, bool, dict[str, Any]]): """Test if the authorization for the given endpoint works""" - endpoint, method, csrf, allowed = auth_test + endpoint, method, csrf, allowed, data = auth_test - response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) + response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}, data = data) assert allowed == (response.status_code != 403) def data_field_type(self, test: tuple[str, Any, str, dict[str, Any]]): @@ -118,7 +118,7 @@ def query_parameter(self, test: tuple[str, Any, str, bool]): response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) if wrong_parameter: - assert wrong_parameter == (response.status_code == 200) + assert wrong_parameter == (response.status_code != 200) if not wrong_parameter: assert response.json["data"] == [] diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index dca6bd83..5e34ed0c 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -1,147 +1,218 @@ """Test the submissions API endpoint""" from os import getenv +from typing import Any + +from pytest import mark from flask.testing import FlaskClient -from sqlalchemy.orm import Session -from tests.utils.auth_login import get_csrf_from_login + +from project.models.user import User from project.models.project import Project from project.models.submission import Submission +from tests.utils.auth_login import get_csrf_from_login +from tests.endpoints.endpoint import ( + TestEndpoint, + authentication_tests, + authorization_tests, + query_parameter_tests +) API_HOST = getenv("API_HOST") -class TestSubmissionsEndpoint: +class TestSubmissionsEndpoint(TestEndpoint): """Class to test the submissions API endpoint""" - ### GET SUBMISSIONS ### - def test_get_submissions_wrong_user(self, client: FlaskClient): - """Test getting submissions for a non-existing user""" - csrf = get_csrf_from_login(client, "teacher") - response = client.get("/submissions?uid=-20", headers = {"X-CSRF-TOKEN":csrf}) + ### AUTHENTICATION ### + # Where is login required + authentication_tests = \ + authentication_tests("/submissions", ["get", "post"]) + \ + authentication_tests("/submissions/@submission_id", ["get", "patch"]) + \ + authentication_tests("/submissions/@submission_id/download", ["get"]) + + @mark.parametrize("auth_test", authentication_tests, indirect=True) + def test_authentication(self, auth_test: tuple[str, Any, str, bool, dict[str, Any]]): + """Test the authentication""" + super().authentication(auth_test) + + + + ### AUTHORIZATION ### + # Who can access what + authorization_tests = \ + authorization_tests("/submissions", "get", + ["student", "student_other", "teacher", "teacher_other", "admin", "admin_other"], + []) + \ + authorization_tests("/submissions", "post", + ["student"], + ["student_other", "teacher", "teacher_other", "admin", "admin_other"]) + \ + authorization_tests("/submissions/@submission_id", "get", + ["student", "teacher", "admin"], + ["student_other", "teacher_other", "admin_other"]) + \ + authorization_tests("submissions/@submission_id", "patch", + ["teacher", "admin"], + ["student", "student_other", "teacher_other", "admin_other"]) + \ + authorization_tests("submissions/@submission_id/download", "get", + ["student", "teacher", "admin"], + ["student_other", "teacher_other", "admin_other"]) + + @mark.parametrize("auth_test", authorization_tests, indirect=True) + def test_authorization(self, auth_test: tuple[str, Any, str, bool, dict[str, Any]]): + """Test the authorization""" + super().authorization(auth_test) + + + + ### QUERY PARAMETER ### + # Test a query parameter, should return [] for wrong values + query_parameter_tests = \ + query_parameter_tests("/submissions", "get", "student", ["uid", "project_id"]) + + @mark.parametrize("query_parameter_test", query_parameter_tests, indirect=True) + def test_query_parameters(self, query_parameter_test: tuple[str, Any, str, bool]): + """Test a query parameter""" + super().query_parameter(query_parameter_test) + + + + ### SUBMISSIONS ### + def test_get_submissions(self, client: FlaskClient, api_host: str, submission: Submission): + """Test getting all submissions""" + response = client.get( + "/submissions", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")} + ) assert response.status_code == 200 - assert response.json["data"] == [] + data = response.json["data"][0] + assert data["submission_id"] == f"{api_host}/submissions/{submission.submission_id}" - def test_get_submissions_wrong_project(self, client: FlaskClient): - """Test getting submissions for a non-existing project""" - csrf = get_csrf_from_login(client, "teacher") - response = client.get("/submissions?project_id=123456789", - headers = {"X-CSRF-TOKEN":csrf}) + def test_get_submissions_user( + self, client: FlaskClient, api_host: str, student: User, submission: Submission + ): + """Test getting all submissions for a given user""" + response = client.get( + f"/submissions?uid={student.uid}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")} + ) assert response.status_code == 200 - assert response.json["data"] == [] - assert "message" in response.json - - def test_get_submissions_wrong_project_type(self, client: FlaskClient): - """Test getting submissions for a non-existing project of the wrong type""" - csrf = get_csrf_from_login(client, "teacher") - response = client.get("/submissions?project_id=zero", headers = {"X-CSRF-TOKEN":csrf}) - assert response.status_code == 400 - assert "message" in response.json - - def test_get_submissions_project(self, client: FlaskClient, valid_submission_entry): - """Test getting the submissions given a specific project""" - csrf = get_csrf_from_login(client, "teacher") - response = client.get(f"/submissions?project_id={valid_submission_entry.project_id}", - headers = {"X-CSRF-TOKEN":csrf}) - data = response.json + data = response.json["data"][0] + assert data["submission_id"] == f"{api_host}/submissions/{submission.submission_id}" + assert data["uid"] == f"{api_host}/users/{student.uid}" + + def test_get_submissions_project( + self, client: FlaskClient, api_host: str, project: Project, submission: Submission + ): + """Test getting all submissions for a given project""" + response = client.get( + f"/submissions?project_id={project.project_id}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")} + ) assert response.status_code == 200 - assert "message" in data - - def test_get_submission_wrong_parameter(self, client: FlaskClient): - """Test a submission filtering on a non existing parameter""" + data = response.json["data"][0] + assert data["submission_id"] == f"{api_host}/submissions/{submission.submission_id}" + assert data["project_id"] == f"{api_host}/projects/{project.project_id}" + + def test_get_submissions_user_project( + self, client: FlaskClient, api_host: str, + student: User, project: Project, submission: Submission + ): + """Test getting all submissions for a given user and project""" + response = client.get( + f"/submissions?uid={student.uid}&project_id={project.project_id}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")} + ) + assert response.status_code == 200 + data = response.json["data"][0] + assert data["submission_id"] == f"{api_host}/submissions/{submission.submission_id}" + assert data["uid"] == f"{api_host}/users/{student.uid}" + assert data["project_id"] == f"{api_host}/projects/{project.project_id}" + + def test_post_submissions(self, client: FlaskClient, project: Project, files): + """Test posting a submission""" + csrf = get_csrf_from_login(client, "student") + response = client.post( + "/submissions", + headers = {"X-CSRF-TOKEN":csrf}, + data = {"project_id":project.project_id, "files": files} + ) + assert response.status_code == 201 + submission_id = response.json["data"]["submission_id"].split("/")[-1] response = client.get( - "/submissions?parameter=0", - headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "teacher")} + f"/submissions/{submission_id}", + headers = {"X-CSRF-TOKEN":csrf} + ) + assert response.status_code == 200 + + def test_post_submissions_invalid_project_id(self, client: FlaskClient, files): + """Test posting a submission when given an invalid project""" + response = client.post( + "/submissions", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")}, + data = {"project_id":"zero", "files": files} ) assert response.status_code == 400 + def test_post_submissions_invalid_file(self, client: FlaskClient, file_no_name): + """Test posting a submission when given a file with no name""" + response = client.post( + "/submissions", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")}, + data = {"project_id":"zero", "files": file_no_name} + ) + assert response.status_code == 400 - ### GET SUBMISSION ### - def test_get_submission_wrong_id(self, client: FlaskClient, session: Session): - """Test getting a submission for a non-existing submission id""" - csrf = get_csrf_from_login(client, "ad3_teacher") - response = client.get("/submissions/0", headers = {"X-CSRF-TOKEN":csrf}) - data = response.json - assert response.status_code == 404 - assert data["message"] == "Submission with id: 0 not found" - def test_get_submission_correct(self, client: FlaskClient, session: Session): + ### SUBMISSION ### + def test_get_submission(self, client: FlaskClient, api_host: str, submission: Submission): """Test getting a submission""" - project = session.query(Project).filter_by(title="B+ Trees").first() - submission = session.query(Submission).filter_by( - uid="student01", project_id=project.project_id - ).first() - csrf = get_csrf_from_login(client, "ad3_teacher") - response = client.get(f"/submissions/{submission.submission_id}", - headers = {"X-CSRF-TOKEN":csrf}) - data = response.json + response = client.get( + f"/submissions/{submission.submission_id}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")} + ) assert response.status_code == 200 - assert data["message"] == "Successfully fetched the submission" - assert data["data"] == { - "submission_id": f"{API_HOST}/submissions/{submission.submission_id}", - "uid": f"{API_HOST}/users/student01", - "project_id": f"{API_HOST}/projects/{project.project_id}", - "grading": 16, - "submission_time": "Thu, 14 Mar 2024 12:00:00 GMT", - "submission_status": 'SUCCESS' - } - - ### PATCH SUBMISSION ### - def test_patch_submission_wrong_id(self, client: FlaskClient, session: Session): - """Test patching a submission for a non-existing submission id""" - csrf = get_csrf_from_login(client, "ad3_teacher") - response = client.patch("/submissions/0", data={"grading": 20}, - headers = {"X-CSRF-TOKEN":csrf}) - data = response.json - assert response.status_code == 404 - assert data["message"] == "Submission with id: 0 not found" - - def test_patch_submission_wrong_grading(self, client: FlaskClient, session: Session): - """Test patching a submission with a wrong grading""" - project = session.query(Project).filter_by(title="B+ Trees").first() - submission = session.query(Submission).filter_by( - uid="student02", project_id=project.project_id - ).first() - csrf = get_csrf_from_login(client, "ad3_teacher") - response = client.patch(f"/submissions/{submission.submission_id}", - data={"grading": 100}, - headers = {"X-CSRF-TOKEN":csrf}) - data = response.json - assert response.status_code == 400 - assert data["message"] == "Invalid grading (grading=0-20)" - - def test_patch_submission_wrong_grading_type(self, client: FlaskClient, session: Session): - """Test patching a submission with a wrong grading type""" - project = session.query(Project).filter_by(title="B+ Trees").first() - submission = session.query(Submission).filter_by( - uid="student02", project_id=project.project_id - ).first() - csrf = get_csrf_from_login(client, "ad3_teacher") - response = client.patch(f"/submissions/{submission.submission_id}", - data={"grading": "zero"}, - headers = {"X-CSRF-TOKEN":csrf}) - data = response.json + data = response.json["data"] + assert data["submission_id"] == f"{api_host}/submissions/{submission.submission_id}" + + def test_patch_submission_grading( + self, client: FlaskClient, api_host: str, submission: Submission + ): + """Test patching the grading to a submission""" + response = client.patch( + f"/submissions/{submission.submission_id}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "teacher")}, + data = {"grading":20} + ) + assert response.status_code == 200 + data = response.json["data"] + assert data["submission_id"] == f"{api_host}/submissions/{submission.submission_id}" + assert data["grading"] == 20.0 + + def test_patch_submission_invalid_grading(self, client: FlaskClient, submission: Submission): + """Test posting a submission when given an invalid project""" + response = client.patch( + f"/submissions/{submission.submission_id}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "teacher")}, + data = {"grading":"zero"} + ) assert response.status_code == 400 - assert data["message"] == "Invalid grading (not a valid float)" - - def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Session): - """Test patching a submission""" - project = session.query(Project).filter_by(title="B+ Trees").first() - submission = session.query(Submission).filter_by( - uid="student02", project_id=project.project_id - ).first() - csrf = get_csrf_from_login(client, "ad3_teacher") - response = client.patch(f"/submissions/{submission.submission_id}", - data={"grading": 20}, - headers = {"X-CSRF-TOKEN":csrf}) - data = response.json + + + + ### SUBMISSION DOWNLOAD ### + def test_get_submission_download( + self, client: FlaskClient, project: Project, files + ): + """Test downloading a submission""" + csrf = get_csrf_from_login(client, "student") + response = client.post( + "/submissions", + headers = {"X-CSRF-TOKEN":csrf}, + data = {"project_id":project.project_id, "files": files} + ) + assert response.status_code == 201 + submission_id = response.json["data"]["submission_id"].split("/")[-1] + response = client.get( + f"/submissions/{submission_id}/download", + headers = {"X-CSRF-TOKEN":csrf} + ) assert response.status_code == 200 - assert data["message"] == f"Submission (submission_id={submission.submission_id}) patched" - assert data["url"] == f"{API_HOST}/submissions/{submission.submission_id}" - assert data["data"] == { - "submission_id": f"{API_HOST}/submissions/{submission.submission_id}", - "uid": f"{API_HOST}/users/student02", - "project_id": f"{API_HOST}/projects/{project.project_id}", - "grading": 20, - "submission_time": 'Thu, 14 Mar 2024 23:59:59 GMT', - "submission_status": 'FAIL' - } From 49462fb8dc01c2fc14eee3b0b212316ce88f0283 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sat, 18 May 2024 16:50:20 +0200 Subject: [PATCH 340/377] Ignore bad filters on fetches to endpoints (#360) * test * submissions now ignore bad filters * small oopsies * getatribute(Model, key, None), i forgot the None * table__columns --- backend/project/endpoints/courses/courses.py | 4 ++-- backend/project/endpoints/projects/projects.py | 3 ++- backend/project/endpoints/submissions/submissions.py | 8 ++------ backend/tests/endpoints/submissions_test.py | 2 +- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index 6d4409b6..eda221f3 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -54,8 +54,8 @@ def get(self, uid=None): # Apply filters dynamically if they are provided for param, value in filter_params.items(): if value: - attribute = getattr(Course, param, None) - if attribute: + if param in Course.__table__.columns: + attribute = getattr(Course, param) base_query = base_query.filter(attribute == value) # Define the role-specific queries diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index e19538ad..27b603a3 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -54,7 +54,8 @@ def get(self, uid=None): filters = dict(request.args) conditions = [] for key, value in filters.items(): - conditions.append(getattr(Project, key) == value) + if key in Project.__table__.columns: + conditions.append(getattr(Project, key) == value) # Get the projects projects = Project.query diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index 4e72b1ff..96e18d2d 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -44,11 +44,6 @@ def get(self, uid=None) -> dict[str, any]: } filters = dict(request.args) try: - invalid_parameters = set(filters.keys()) - {"uid", "project_id"} - if invalid_parameters: - data["message"] = f"Invalid query parameter(s) {invalid_parameters}" - return data, 400 - # Check the uid query parameter user_id = filters.get("uid") if user_id and not isinstance(user_id, str): @@ -73,7 +68,8 @@ def get(self, uid=None) -> dict[str, any]: # Filter the courses based on the query parameters conditions = [] for key, value in filters.items(): - conditions.append(getattr(Submission, key) == value) + if key in Submission.__table__.columns: + conditions.append(getattr(Submission, key) == value) # Get the submissions submissions = Submission.query diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 5e34ed0c..083aeee5 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -158,7 +158,7 @@ def test_post_submissions_invalid_file(self, client: FlaskClient, file_no_name): headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")}, data = {"project_id":"zero", "files": file_no_name} ) - assert response.status_code == 400 + assert response.status_code == 200 From 76f90a34897472051e591d18b9929f58cfb73888 Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Sat, 18 May 2024 16:51:02 +0200 Subject: [PATCH 341/377] Teacher is now listed as admin on course page (#349) * teacher is also listed, useMemo instead of useEffect to prevent listing twice * useMemo bad, reworked the student and admin fetchers * all fetches straight to loader fetches Me objects after fetching admins and students, no need for useffect * fixed loader and seeder --- backend/seeder/seeder.py | 10 ++--- .../Courses/CourseDetailTeacher.tsx | 39 ++++--------------- .../src/components/Courses/CourseUtils.tsx | 10 ++++- 3 files changed, 19 insertions(+), 40 deletions(-) diff --git a/backend/seeder/seeder.py b/backend/seeder/seeder.py index a85be82d..d56d0e0f 100644 --- a/backend/seeder/seeder.py +++ b/backend/seeder/seeder.py @@ -170,7 +170,7 @@ def into_the_db(my_uid): subscribed_students = populate_course_students( session, course_id, students) populate_course_projects( - session, course_id, subscribed_students, my_uid) + session, course_id, subscribed_students) for _ in range(5): # 5 courses where my_uid is a student teacher_uid = teachers[random.randint(0, len(teachers)-1)].uid @@ -181,7 +181,7 @@ def into_the_db(my_uid): session.commit() subscribed_students.append(my_uid) # my_uid is also a student populate_course_projects( - session, course_id, subscribed_students, teacher_uid) + session, course_id, subscribed_students) except SQLAlchemyError as e: if session: # possibly error resulted in session being null session.rollback() @@ -211,12 +211,8 @@ def populate_course_students(session, course_id, students): return [student.uid for student in subscribed_students] -def populate_course_projects(session, course_id, students, teacher_uid): +def populate_course_projects(session, course_id, students): """Populates the course with projects and submissions, also creates the files""" - teacher_relation = course_admin_generator(course_id, teacher_uid) - session.add(teacher_relation) - session.commit() - num_projects = random.randint(1, 3) projects = generate_projects(course_id, num_projects) session.add_all(projects) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index 24d816ee..88030b05 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -24,7 +24,6 @@ import { apiHost, getIdFromLink, getNearestFutureDate, - getUser, ProjectDetail, } from "./CourseUtils"; import { @@ -128,42 +127,18 @@ export function CourseDetailTeacher(): JSX.Element { const courseDetail = useLoaderData() as { course: Course; projects: ProjectDetail[]; - admins: UserUid[]; - students: UserUid[]; + adminMes: Me[]; + studentMes: Me[]; }; - - const { course, projects, admins, students } = courseDetail; - const [adminObjects, setAdminObjects] = useState([]); - const [studentObjects, setStudentObjects] = useState([]); + const { course, projects, adminMes, studentMes } = courseDetail; const { t } = useTranslation("translation", { keyPrefix: "courseDetailTeacher", }); + const { i18n } = useTranslation(); const lang = i18n.language; const navigate = useNavigate(); - useEffect(() => { - setAdminObjects([]); - admins.forEach((admin) => { - getUser(admin.uid).then((user: Me) => { - setAdminObjects((prev) => { - return [...prev, user]; - }); - }); - }); - }, [admins]); - - useEffect(() => { - setStudentObjects([]); - students.forEach((student) => { - getUser(student.uid).then((user: Me) => { - setStudentObjects((prev) => { - return [...prev, user]; - }); - }); - }); - }, [students]); - const handleCheckboxChange = ( event: ChangeEvent, uid: string @@ -176,7 +151,7 @@ export function CourseDetailTeacher(): JSX.Element { ); } }; - + return ( <> @@ -216,7 +191,7 @@ export function CourseDetailTeacher(): JSX.Element { > {t("admins")}: - {adminObjects.map((admin) => ( + {adminMes.map((admin: Me) => ( {t("students")}: diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 9b6610a5..68c81383 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -224,6 +224,10 @@ const dataLoaderStudents = async (courseId: string) => { return fetchData(`courses/${courseId}/students`); }; +const fetchMes = async (uids: string[]) => { + return Promise.all(uids.map((uid) => getUser(uid))); +} + export const dataLoaderCourseDetail = async ({ params, }: { @@ -237,5 +241,9 @@ export const dataLoaderCourseDetail = async ({ const projects = await dataLoaderProjects(courseId); const admins = await dataLoaderAdmins(courseId); const students = await dataLoaderStudents(courseId); - return { course, projects, admins, students }; + const admin_uids = admins.map((admin: {uid: string}) => getIdFromLink(admin.uid)); + const student_uids = students.map((student: {uid: string}) => getIdFromLink(student.uid)); + const adminMes = await fetchMes([course.teacher, ...admin_uids]); + const studentMes = await fetchMes(student_uids); + return { course, projects, adminMes, studentMes }; }; From f7d2992d0ccffa9bed65a50d22f9a4662505ac59 Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Sun, 19 May 2024 17:02:13 +0200 Subject: [PATCH 342/377] fix join codes bug (#374) --- frontend/src/loaders/join-code.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/loaders/join-code.ts b/frontend/src/loaders/join-code.ts index f559723b..bfc08b1b 100644 --- a/frontend/src/loaders/join-code.ts +++ b/frontend/src/loaders/join-code.ts @@ -14,8 +14,7 @@ export async function synchronizeJoinCode() { if (joinCode) { const response = await authenticatedFetch( - new URL("/courses/join", API_URL), - { + `${API_URL}/courses/join`,{ method: "POST", body: JSON.stringify({ join_code: joinCode }), headers: { "Content-Type": "application/json" }, From d145934619a6a96050068af61b4afe9866268a46 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Sun, 19 May 2024 17:04:14 +0200 Subject: [PATCH 343/377] Fix en-us bug (#366) * fix en-us bug on homepage * should be fixed everywhere now * actually fixed hopefully * fix bug req. change --- .../src/components/Courses/CourseDetailTeacher.tsx | 10 ++++------ .../src/components/Courses/CourseUtilComponents.tsx | 4 ++-- frontend/src/components/LanguagePath.tsx | 7 ++++--- frontend/src/components/ProjectForm/ProjectForm.tsx | 2 +- frontend/src/i18n.js | 4 +++- .../project/projectDeadline/ProjectDeadlineCard.tsx | 9 ++++----- frontend/src/pages/project/projectOverview.tsx | 6 +++--- frontend/src/pages/project/projectView/ProjectView.tsx | 4 ++-- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index 88030b05..6ba52b63 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -134,9 +134,7 @@ export function CourseDetailTeacher(): JSX.Element { const { t } = useTranslation("translation", { keyPrefix: "courseDetailTeacher", }); - - const { i18n } = useTranslation(); - const lang = i18n.language; + const lang = i18next.resolvedLanguage; const navigate = useNavigate(); const handleCheckboxChange = ( @@ -304,7 +302,7 @@ function EmptyOrNotProjects({ @@ -317,7 +315,7 @@ function EmptyOrNotProjects({ @@ -463,7 +461,7 @@ function JoinCodeMenu({ const handleCopyToClipboard = (join_code: string) => { const host = window.location.host; navigator.clipboard.writeText( - `${host}/${i18next.language}/courses/join?code=${join_code}` + `${host}/${i18next.resolvedLanguage}/courses/join?code=${join_code}` ); }; diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx index 08495726..2c33db07 100644 --- a/frontend/src/components/Courses/CourseUtilComponents.tsx +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -20,6 +20,7 @@ import { Link, useNavigate, useLocation } from "react-router-dom"; import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import debounce from "debounce"; +import i18next from "i18next"; /** * @param text - The text to be displayed @@ -263,8 +264,7 @@ function EmptyOrNotProjects({ projects: ProjectDetail[]; noProjectsText: string; }): JSX.Element { - const { i18n } = useTranslation(); - const lang = i18n.language; + const lang = i18next.resolvedLanguage; if (projects === undefined || projects.length === 0) { return ( { - if (lang && i18n.resolvedLanguage !== lang) { + if (lang && i18next.resolvedLanguage !== lang) { if (SUPPORTED_LANGUAGES.includes(lang)) { - i18n.changeLanguage(lang); + i18next.changeLanguage(lang); } else { - navigate("/" + i18n.resolvedLanguage + curPath, { replace: true }); + navigate("/" + i18next.resolvedLanguage + curPath, { replace: true }); } } }, [lang, curPath, i18n, navigate]); diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index 12eb81ff..39f81dc0 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -239,7 +239,7 @@ export default function ProjectForm() { response.json().then((data) => { const projectData = data.data; - navigate(`/${i18next.language}/projects/${projectData.project_id}`); + navigate(`/${i18next.resolvedLanguage}/projects/${projectData.project_id}`); }) } diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index 0c5090bb..a6055822 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -18,7 +18,9 @@ i18n detection: detectionOptions, interpolation: { escapeValue: false, - } + }, + supportedLngs: ['en', 'nl'], + nonExplicitSupportedLngs: true }); export default i18n; diff --git a/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx index 129e0ee4..28f0583f 100644 --- a/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx +++ b/frontend/src/pages/project/projectDeadline/ProjectDeadlineCard.tsx @@ -1,10 +1,10 @@ -import {CardActionArea, Card, CardContent, Typography, Box, Button} from '@mui/material'; -import {Link } from "react-router-dom"; +import {CardActionArea, Card, CardContent, Link, Typography, Box, Button} from '@mui/material'; import { useTranslation } from "react-i18next"; import dayjs from "dayjs"; import {ProjectDeadline} from "./ProjectDeadline.tsx"; import React from "react"; import { useNavigate } from 'react-router-dom'; +import i18next from 'i18next'; interface ProjectCardProps{ deadlines:ProjectDeadline[], @@ -19,7 +19,6 @@ interface ProjectCardProps{ */ export const ProjectDeadlineCard: React.FC = ({ deadlines, showCourse = true }) => { const { t } = useTranslation('translation', { keyPrefix: 'student' }); - const { i18n } = useTranslation(); const navigate = useNavigate(); //list of the corresponding assignment @@ -28,7 +27,7 @@ export const ProjectDeadlineCard: React.FC = ({ deadlines, sh {deadlines.map((project, index) => ( - + = ({ deadlines, sh onClick={(event) => { event.stopPropagation(); // stops the event from reaching CardActionArea event.preventDefault(); - navigate(`/${i18n.language}/courses/${project.course.course_id}`) + navigate(`/${i18next.resolvedLanguage}/courses/${project.course.course_id}`) }} > {project.course.name} diff --git a/frontend/src/pages/project/projectOverview.tsx b/frontend/src/pages/project/projectOverview.tsx index 8f2a5a5c..320ccb00 100644 --- a/frontend/src/pages/project/projectOverview.tsx +++ b/frontend/src/pages/project/projectOverview.tsx @@ -5,13 +5,13 @@ import { useTranslation } from "react-i18next"; import {Title} from "../../components/Header/Title.tsx"; import {useLoaderData, Link as RouterLink} from "react-router-dom"; import dayjs from "dayjs"; +import i18next from "i18next"; /** * Displays all the projects * @returns the project page */ export default function ProjectOverView() { - const {i18n} = useTranslation() const { t } = useTranslation('translation', { keyPrefix: 'projectsOverview' }); const loader = useLoaderData() as { projects: ProjectDeadline[], @@ -39,7 +39,7 @@ export default function ProjectOverView() { return ( - {courseProjects[0].course.name} {courseProjects[0].course.ufora_id} @@ -56,7 +56,7 @@ export default function ProjectOverView() { {me === 'TEACHER' && ( - + )} diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index dca59a50..209279de 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -56,7 +56,7 @@ export default function ProjectView() { }); authenticatedFetch( - `${API_URL}/projects/${projectId}/assignment?lang=${i18next.language}` + `${API_URL}/projects/${projectId}/assignment?lang=${i18next.resolvedLanguage}` ).then((response) => { if (response.ok) { response.text().then((data) => setAssignmentRawText(data)); @@ -88,7 +88,7 @@ export default function ProjectView() { {projectData.description} {courseData && ( - + {courseData.name} )} From 214dcadb19cb129eeedbea3116a3c0625881e733 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 20 May 2024 10:39:31 +0200 Subject: [PATCH 344/377] Fix the filter tests (#376) * changed 200 to 400 * changed != to == * added filter functionlity to submissions * removed prints * linter * linter again * filters that aren't fields are now ignored in the code, tests for this are fixed * yeet * idk tbh --- backend/project/endpoints/courses/courses.py | 13 +++++-------- .../project/endpoints/submissions/submissions.py | 7 +++++++ backend/tests/endpoints/endpoint.py | 2 +- backend/tests/endpoints/submissions_test.py | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/backend/project/endpoints/courses/courses.py b/backend/project/endpoints/courses/courses.py index eda221f3..306df744 100644 --- a/backend/project/endpoints/courses/courses.py +++ b/backend/project/endpoints/courses/courses.py @@ -39,14 +39,11 @@ def get(self, uid=None): """ try: - filter_params = request.args.to_dict() - - invalid_params = set(filter_params.keys()) - {f.name for f in fields(Course)} - if invalid_params: - return { - "url": RESPONSE_URL, - "message": f"Invalid query parameters {invalid_params}" - }, 400 + filter_params = { + key: value for key, value + in request.args.to_dict().items() + if key in {f.name for f in fields(Course)} + } # Start with a base query base_query = select(Course) diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index 96e18d2d..a47ca63b 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -43,6 +43,7 @@ def get(self, uid=None) -> dict[str, any]: "url": BASE_URL } filters = dict(request.args) + try: # Check the uid query parameter user_id = filters.get("uid") @@ -58,6 +59,12 @@ def get(self, uid=None) -> dict[str, any]: return data, 400 filters["project_id"] = int(project_id) + filters = { + key: value for key, value + in filters.items() + if key in Submission.__table__.columns + } + # Get the courses courses = Course.query.filter_by(teacher=uid).\ with_entities(Course.course_id).all() diff --git a/backend/tests/endpoints/endpoint.py b/backend/tests/endpoints/endpoint.py index 2d686f0d..09767842 100644 --- a/backend/tests/endpoints/endpoint.py +++ b/backend/tests/endpoints/endpoint.py @@ -118,7 +118,7 @@ def query_parameter(self, test: tuple[str, Any, str, bool]): response = method(endpoint, headers = {"X-CSRF-TOKEN":csrf}) if wrong_parameter: - assert wrong_parameter == (response.status_code != 200) + assert wrong_parameter == (response.status_code == 200) if not wrong_parameter: assert response.json["data"] == [] diff --git a/backend/tests/endpoints/submissions_test.py b/backend/tests/endpoints/submissions_test.py index 083aeee5..5e34ed0c 100644 --- a/backend/tests/endpoints/submissions_test.py +++ b/backend/tests/endpoints/submissions_test.py @@ -158,7 +158,7 @@ def test_post_submissions_invalid_file(self, client: FlaskClient, file_no_name): headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")}, data = {"project_id":"zero", "files": file_no_name} ) - assert response.status_code == 200 + assert response.status_code == 400 From 587423b79b13870f230172a4009bca095c9d4276 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 20 May 2024 10:49:15 +0200 Subject: [PATCH 345/377] Documentation/fix/GitHub link (#378) * added table to frontend readme * added .env vars to the README * updated readme * changed URL of api part * changed github url * changes not for this pr reverted * newline goofy augh --- documentation/docusaurus.config.ts | 4 ++-- frontend/README.md | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/documentation/docusaurus.config.ts b/documentation/docusaurus.config.ts index f0fc50a8..bf28738f 100644 --- a/documentation/docusaurus.config.ts +++ b/documentation/docusaurus.config.ts @@ -38,7 +38,7 @@ const config: Config = { // Please change this to your repo. // Remove this to remove the "edit this page" links. editUrl: - 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/', + 'https://github.com/SELab-2/UGent-3', }, theme: { customCss: './src/css/custom.css', @@ -64,7 +64,7 @@ const config: Config = { label: 'User guide', }, { - href: 'https://github.com/facebook/docusaurus', + href: 'https://github.com/SELab-2/UGent-3', label: 'GitHub', position: 'right', }, diff --git a/frontend/README.md b/frontend/README.md index 49c9c481..a1ef1856 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -50,5 +50,4 @@ If you want to execute the linter on all files in the project it can simply be d with the command: ```sh npm run lint -``` - +``` From dfc49109b6effb476e40ef78a4bad5efe628011e Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 20 May 2024 11:13:20 +0200 Subject: [PATCH 346/377] patch score in backend when edited in the frontend and i18n for page (#355) * score patch is working * changes * added error message and i18n support * added tooltip instead of alert :) * linter --- .../submissions/submission_detail.py | 1 - .../public/locales/en/submissionOverview.json | 8 +- .../public/locales/nl/submissionOverview.json | 12 ++ .../locales/nl/submissionsOverview.json | 6 - .../ProjectSubmissionOverviewDatagrid.tsx | 147 +++++++++++++----- frontend/src/types/submission.ts | 1 + 6 files changed, 129 insertions(+), 46 deletions(-) create mode 100644 frontend/public/locales/nl/submissionOverview.json delete mode 100644 frontend/public/locales/nl/submissionsOverview.json diff --git a/backend/project/endpoints/submissions/submission_detail.py b/backend/project/endpoints/submissions/submission_detail.py index 81b9c783..8574cfb3 100644 --- a/backend/project/endpoints/submissions/submission_detail.py +++ b/backend/project/endpoints/submissions/submission_detail.py @@ -107,7 +107,6 @@ def patch(self, submission_id:int) -> dict[str, any]: # Save the submission session.commit() - data["message"] = f"Submission (submission_id={submission_id}) patched" data["url"] = urljoin(f"{BASE_URL}/", str(submission.submission_id)) data["data"] = submission_response(submission, API_HOST) diff --git a/frontend/public/locales/en/submissionOverview.json b/frontend/public/locales/en/submissionOverview.json index 44ad27d3..97e1fc19 100644 --- a/frontend/public/locales/en/submissionOverview.json +++ b/frontend/public/locales/en/submissionOverview.json @@ -1,6 +1,12 @@ { "submissionOverview": { "submissionOverviewHeader": "Project status overview", - "downloadButton": "DOWNLOAD ALL PROJECTS" + "downloadButton": "DOWNLOAD ALL PROJECTS", + "scoreError": "Grade must be between 0 and 20", + "submissionID": "Submission ID", + "student": "Student", + "grading": "Grading", + "status": "Status", + "download": "Download" } } \ No newline at end of file diff --git a/frontend/public/locales/nl/submissionOverview.json b/frontend/public/locales/nl/submissionOverview.json new file mode 100644 index 00000000..e2b0cf35 --- /dev/null +++ b/frontend/public/locales/nl/submissionOverview.json @@ -0,0 +1,12 @@ +{ + "submissionOverview": { + "submissionOverviewHeader": "Project status overzicht", + "downloadButton": "DOWNLOAD ALLE PROJECTEN", + "scoreError": "Score moeten tussen 0 en 20 liggen", + "submissionID": "Indiening ID", + "student": "Student", + "grading": "Score", + "status": "Status", + "download": "Download" + } +} \ No newline at end of file diff --git a/frontend/public/locales/nl/submissionsOverview.json b/frontend/public/locales/nl/submissionsOverview.json deleted file mode 100644 index 1a2e172b..00000000 --- a/frontend/public/locales/nl/submissionsOverview.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "submissionOverview": { - "submissionOverviewHeader": "Project status overzicht", - "downloadButton": "DOWNLOAD ALLE PROJECTEN" - } -} \ No newline at end of file diff --git a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx index 171c7976..1dd22b0c 100644 --- a/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx +++ b/frontend/src/components/ProjectSubmissionOverview/ProjectSubmissionOverviewDatagrid.tsx @@ -1,5 +1,11 @@ -import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; -import { Box, IconButton } from "@mui/material"; +import { + DataGrid, + GridEditInputCell, + GridPreProcessEditCellProps, + GridRenderCellParams, + GridRenderEditCellParams +} from "@mui/x-data-grid"; +import {Box, IconButton, styled, Tooltip, tooltipClasses, TooltipProps} from "@mui/material"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import { green, red } from "@mui/material/colors"; import CancelIcon from "@mui/icons-material/Cancel"; @@ -7,7 +13,8 @@ import DownloadIcon from "@mui/icons-material/Download"; import download from "downloadjs"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; import { Submission } from "../../types/submission"; - +import {useTranslation} from "react-i18next"; +import {TFunction} from "i18next"; const APIURL = import.meta.env.VITE_APP_API_HOST; /** @@ -27,57 +34,121 @@ const fetchSubmissionsFromUser = async (submission_id: string) => { }); }; -const columns: GridColDef[] = [ - { field: "submission_id", headerName: "Submission ID", flex: 0.4 }, - { field: "display_name", headerName: "Student", width: 160, flex: 0.4 }, - { - field: "grading", - headerName: "Grading", - editable: true, - flex: 0.2, - }, - { - field: "submission_status", - headerName: "Status", - renderCell: (params: GridRenderCellParams) => ( - <> - {params.row.submission_status === "SUCCESS" ? ( - - ) : ( - - )} - - ), - }, - { - field: "submission_path", - headerName: "Download", - renderCell: (params: GridRenderCellParams) => ( - fetchSubmissionsFromUser(params.row.submission_id)} - > - - - ), +const editGrade = (submission: Submission, errorMessage: string) => { + const submission_id = submission.submission_id; + const newGrade = submission.grading; + + if (newGrade < 0 || newGrade > 20) { + throw new Error(errorMessage); + } + + const formData = new FormData(); + formData.append('grading', newGrade.toString()); + + authenticatedFetch(`${APIURL}/submissions/${submission_id}`, { + method: "PATCH", + body: formData + }) + + return submission +}; + +const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: theme.palette.error.main, + color: theme.palette.error.contrastText, }, -]; +})); /** - * @returns the datagrid for displaying submissiosn + * @returns Component for input edit cell + */ +function NameEditInputCell(props: GridRenderEditCellParams) { + const { error, msg } = props; + + return ( + + + + ); +} + +/** + * @returns component for passing params + */ +function renderEditScore(params: GridRenderEditCellParams) { + return ; +} + +const getTranslatedRows = (t: TFunction) => { + return [ + { field: "submission_id", headerName: t("submissionID"), flex: 0.4, editable: false }, + { field: "display_name", headerName: t("student"), width: 160, flex: 0.4, editable: false }, + { + field: "grading", + headerName: t("grading"), + editable: true, + flex: 0.2, + preProcessEditCellProps: (params: GridPreProcessEditCellProps) => { + const hasError = params.props.value > 20 || params.props.value < 0; + return { ...params.props, error: hasError, msg: t("scoreError") }; + }, + renderEditCell: renderEditScore + }, + { + field: "submission_status", + headerName: t("status"), + editable: false, + renderCell: (params: GridRenderCellParams) => ( + <> + {params.row.submission_status === "SUCCESS" ? ( + + ) : ( + + )} + + ), + }, + { + field: "submission_path", + headerName: t("download"), + renderCell: (params: GridRenderCellParams) => ( + fetchSubmissionsFromUser(params.row.submission_id)} + > + + + ), + }, + ]; +} + +/** + * @returns the datagrid for displaying submissions */ export default function ProjectSubmissionsOverviewDatagrid({ submissions, }: { submissions: Submission[]; }) { + + const { t } = useTranslation('submissionOverview', { keyPrefix: 'submissionOverview' }); + + const errorMsg = t("scoreError"); + return ( + editGrade(updatedRow, errorMsg) + } /> ); diff --git a/frontend/src/types/submission.ts b/frontend/src/types/submission.ts index 4eab356f..173970d6 100644 --- a/frontend/src/types/submission.ts +++ b/frontend/src/types/submission.ts @@ -3,4 +3,5 @@ export interface Submission { submission_time: string; submission_status: string; uid: string; + grading: number; } From f366e51ce807c598543b82dba3bc8d49b3cb1eff Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 20 May 2024 12:50:17 +0200 Subject: [PATCH 347/377] update frontend readme variables (#356) * added table to frontend readme * added .env vars to the README * updated readme * changed URL of api part * removed code verfier --- frontend/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/README.md b/frontend/README.md index a1ef1856..b2c30576 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -26,6 +26,17 @@ You can choose your own preferred webserver like for example `nginx`, `serve` or npm run dev ``` +## Setting up the environment variables + +This application requires a couple of environment variables to run. +Setting values for these variables can be done with a method to your own liking. + +| Variable | Description | +|------------------------|------------------------------------------------------------------------------------| +| VITE_API_HOST | URL of the API | +| VITE_APP_TENANT_ID | [Tenant id](https://learn.microsoft.com/nl-nl/entra/fundamentals/whatis) | +| VITE_APP_CLIENT_ID | [Client id](https://learn.microsoft.com/nl-nl/entra/identity-platform/v2-protocols) | + ## Maintaining the codebase ### Writing tests When writing new code it is important to maintain the right functionality so From 86370e652c2992678b929951abe689e513f5f48b Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Mon, 20 May 2024 15:52:18 +0200 Subject: [PATCH 348/377] Frontend testing setup (#290) * testing setup * changed from jest to vitest and changed npm scripts to be easier to work with * hopefully final version of config files * fixed double import react bug * setup finished * linter fix * forgot to push this * any type * requested changes * vitest coverage generates coverage files --- frontend/.gitignore | 2 +- frontend/cypress/tsconfig.json | 8 + frontend/package-lock.json | 5599 +++++++++++++++++--- frontend/package.json | 21 +- frontend/tests/unit/header.test.tsx | 3 + frontend/tests/unit/homepage.test.tsx | 3 + frontend/tests/unit/type-tests/me.test.tsx | 5 + frontend/tests/utils/api.ts | 0 frontend/tests/utils/data.ts | 7 + frontend/tests/utils/utils.ts | 0 frontend/tsconfig.json | 5 +- frontend/vite.config.ts | 5 + 12 files changed, 5052 insertions(+), 606 deletions(-) create mode 100644 frontend/cypress/tsconfig.json create mode 100644 frontend/tests/unit/header.test.tsx create mode 100644 frontend/tests/unit/homepage.test.tsx create mode 100644 frontend/tests/unit/type-tests/me.test.tsx create mode 100644 frontend/tests/utils/api.ts create mode 100644 frontend/tests/utils/data.ts create mode 100644 frontend/tests/utils/utils.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index d7de12f3..0888a05a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,7 +11,7 @@ node_modules dist dist-ssr *.local - +coverage .env # Editor directories and files diff --git a/frontend/cypress/tsconfig.json b/frontend/cypress/tsconfig.json new file mode 100644 index 00000000..18edb199 --- /dev/null +++ b/frontend/cypress/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d8c820b3..9143b918 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,7 +42,8 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.2.1", - "cypress": "^13.7.0", + "@vitest/coverage-v8": "^1.5.2", + "cypress": "^13.8.1", "eslint": "^8.56.0", "eslint-plugin-jsdoc": "^48.1.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -51,10 +52,17 @@ "i18next": "^23.10.1", "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.0", + "jest": "^29.7.0", + "jsdom": "^24.0.0", + "npm-run-all": "^4.1.5", "react-i18next": "^14.1.0", "scheduler": "^0.23.0", - "typescript": "^5.2.2", - "vite": "^5.1.7" + "start-server-and-test": "^2.0.3", + "ts-jest": "^29.1.2", + "typescript": "^5.4.5", + "vite": "^5.1.7", + "vitest": "^1.5.2", + "wait-on": "^7.2.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -66,6 +74,19 @@ "node": ">=0.10.0" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.2.tgz", @@ -87,6 +108,106 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/core": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", + "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.2", + "@babel/generator": "^7.24.4", + "@babel/helper-compilation-targets": "^7.23.6", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.24.4", + "@babel/parser": "^7.24.4", + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", + "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.24.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.23.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", + "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-validator-option": "^7.23.5", + "browserslist": "^4.22.2", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, "node_modules/@babel/helper-environment-visitor": { "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", @@ -109,20 +230,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-function-name/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-hoist-variables": { "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", @@ -135,20 +242,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-hoist-variables/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.24.3", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz", @@ -160,17 +253,23 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/@babel/helper-plugin-utils": { @@ -194,20 +293,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-simple-access/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-split-export-declaration": { "version": "7.22.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", @@ -220,20 +305,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-split-export-declaration/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz", @@ -259,6 +330,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helpers": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", + "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.24.0", + "@babel/traverse": "^7.24.1", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/highlight": { "version": "7.24.2", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.2.tgz", @@ -349,89 +434,301 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/runtime": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", - "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, "dependencies": { - "regenerator-runtime": "^0.14.0" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/template": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", - "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.24.0", - "@babel/types": "^7.24.0" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/template/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-plugin-utils": "^7.12.13" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@cypress/request": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", - "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "http-signature": "~1.3.6", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "performance-now": "^2.1.0", - "qs": "6.10.4", - "safe-buffer": "^5.1.2", - "tough-cookie": "^4.1.3", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" + "@babel/helper-plugin-utils": "^7.10.4" }, - "engines": { - "node": ">= 6" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@cypress/request/node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">= 0.12" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@cypress/xvfb": { + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz", + "integrity": "sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz", + "integrity": "sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", + "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.0.tgz", + "integrity": "sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/parser": "^7.24.0", + "@babel/types": "^7.24.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", + "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.24.1", + "@babel/generator": "^7.24.1", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.24.1", + "@babel/types": "^7.24.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@cypress/request": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", + "dev": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "http-signature": "~1.3.6", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "6.10.4", + "safe-buffer": "^5.1.2", + "tough-cookie": "^4.1.3", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/@cypress/xvfb": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", @@ -1061,6 +1358,21 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "dev": true + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1080,66 +1392,506 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, - "node_modules/@mui/base": { - "version": "5.0.0-beta.40", - "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", - "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.23.9", - "@floating-ui/react-dom": "^2.0.8", - "@mui/types": "^7.2.14", - "@mui/utils": "^5.15.14", - "@popperjs/core": "^2.11.8", - "clsx": "^2.1.0", - "prop-types": "^15.8.1" + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" }, "engines": { - "node": ">=12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/base/node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" + "node": ">=8" } }, - "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.15.tgz", - "integrity": "sha512-aXnw29OWQ6I5A47iuWEI6qSSUfH6G/aCsW9KmW3LiFqr7uXZBK4Ks+z8G+qeIub8k0T5CMqlT2q0L+ZJTMrqpg==", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" } }, - "node_modules/@mui/icons-material": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.15.tgz", - "integrity": "sha512-kkeU/pe+hABcYDH6Uqy8RmIsr2S/y5bP2rp+Gat4CcRjCcVne6KudS1NrZQhUCRysrTDCAhcbcf9gt+/+pGO2g==", + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "dependencies": { - "@babel/runtime": "^7.23.9" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=12.0.0" + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, - "funding": { + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.40", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", + "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/base/node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.15.15", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.15.tgz", + "integrity": "sha512-aXnw29OWQ6I5A47iuWEI6qSSUfH6G/aCsW9KmW3LiFqr7uXZBK4Ks+z8G+qeIub8k0T5CMqlT2q0L+ZJTMrqpg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.15.15", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.15.tgz", + "integrity": "sha512-kkeU/pe+hABcYDH6Uqy8RmIsr2S/y5bP2rp+Gat4CcRjCcVne6KudS1NrZQhUCRysrTDCAhcbcf9gt+/+pGO2g==", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" }, @@ -1688,10 +2440,55 @@ "win32" ] }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "dev": true + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "dev": true + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "dependencies": { "@babel/parser": "^7.20.7", @@ -1701,20 +2498,6 @@ "@types/babel__traverse": "*" } }, - "node_modules/@types/babel__core/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@types/babel__generator": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", @@ -1724,20 +2507,6 @@ "@babel/types": "^7.0.0" } }, - "node_modules/@types/babel__generator/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@types/babel__template": { "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", @@ -1748,20 +2517,6 @@ "@babel/types": "^7.0.0" } }, - "node_modules/@types/babel__template/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@types/babel__traverse": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", @@ -1771,20 +2526,6 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/babel__traverse/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1812,6 +2553,15 @@ "@types/estree": "*" } }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1826,6 +2576,30 @@ "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", "dev": true }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1850,7 +2624,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", "integrity": "sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==", "dev": true, - "optional": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1936,6 +2709,12 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true + }, "node_modules/@types/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.0.tgz", @@ -1946,6 +2725,21 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" }, + "node_modules/@types/yargs": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", + "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2345,265 +3139,173 @@ "vite": "^4.2.0 || ^5.0.0" } }, - "node_modules/@vitejs/plugin-react/node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "node_modules/@vitejs/plugin-react/node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.1.tgz", + "integrity": "sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { - "node": ">=6.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/core": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.4.tgz", - "integrity": "sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==", + "node_modules/@vitejs/plugin-react/node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz", + "integrity": "sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==", "dev": true, "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.4", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.24.4", - "@babel/parser": "^7.24.4", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "@babel/helper-plugin-utils": "^7.24.0" }, "engines": { "node": ">=6.9.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/generator": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.4.tgz", - "integrity": "sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==", + "node_modules/@vitest/coverage-v8": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.5.2.tgz", + "integrity": "sha512-QJqxRnbCwNtbbegK9E93rBmhN3dbfG1bC/o52Bqr0zGCYhQzwgwvrJBG7Q8vw3zilX6Ryy6oa/mkZku2lLJx1Q==", "dev": true, "dependencies": { - "@babel/types": "^7.24.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" }, - "engines": { - "node": ">=6.9.0" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.5.2" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "node_modules/@vitest/coverage-v8/node_modules/istanbul-lib-source-maps": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.4.tgz", + "integrity": "sha512-wHOoEsNJTVltaJp8eVkm8w+GVkVNHT2YDYo53YdzQEL2gWm1hBX5cGFR9hQJtuGLebidVX7et3+dmDZrmclduw==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=10" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "node_modules/@vitest/expect": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.5.2.tgz", + "integrity": "sha512-rf7MTD1WCoDlN3FfYJ9Llfp0PbdtOMZ3FIF0AVkDnKbp3oiMW1c8AmvRZBcqbAhDUAvF52e9zx4WQM1r3oraVA==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" + "@vitest/spy": "1.5.2", + "@vitest/utils": "1.5.2", + "chai": "^4.3.10" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/helpers": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.4.tgz", - "integrity": "sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==", + "node_modules/@vitest/runner": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.5.2.tgz", + "integrity": "sha512-7IJ7sJhMZrqx7HIEpv3WrMYcq8ZNz9L6alo81Y6f8hV5mIE6yVZsFoivLZmr0D777klm1ReqonE9LyChdcmw6g==", "dev": true, "dependencies": { - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.1", - "@babel/types": "^7.24.0" + "@vitest/utils": "1.5.2", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" }, - "engines": { - "node": ">=6.9.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.1.tgz", - "integrity": "sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==", + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "yocto-queue": "^1.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.1.tgz", - "integrity": "sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==", + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" - }, "engines": { - "node": ">=6.9.0" + "node": ">=12.20" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/traverse": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.1.tgz", - "integrity": "sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==", + "node_modules/@vitest/snapshot": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.5.2.tgz", + "integrity": "sha512-CTEp/lTYos8fuCc9+Z55Ga5NVPKUgExritjF5VY7heRFUfheoAqBneUlvXSUJHUZPjnPmyZA96yLRJDP1QATFQ==", "dev": true, "dependencies": { - "@babel/code-frame": "^7.24.1", - "@babel/generator": "^7.24.1", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.24.1", - "@babel/types": "^7.24.0", - "debug": "^4.3.1", - "globals": "^11.1.0" + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" }, - "engines": { - "node": ">=6.9.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitejs/plugin-react/node_modules/@babel/types": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", - "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "node_modules/@vitest/spy": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.5.2.tgz", + "integrity": "sha512-xCcPvI8JpCtgikT9nLpHPL1/81AYqZy1GCy4+MCHBE7xi8jgsYkULpW5hrx5PGLgOQjUpb6fd15lqcriJ40tfQ==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "tinyspy": "^2.2.0" }, - "engines": { - "node": ">=6.9.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "node_modules/@vitest/utils": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.5.2.tgz", + "integrity": "sha512-sWOmyofuXLJ85VvXNsroZur7mOJGiQeM0JN3/0D1uU8U9bGFM69X1iqHaRXl6R8BwaLY6yPCogP257zxTzkUdA==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" }, - "engines": { - "node": ">=6.0.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "node_modules/@vitejs/plugin-react/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/@vitejs/plugin-react/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@vitejs/plugin-react/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -2625,6 +3327,27 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2723,6 +3446,19 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/arch": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", @@ -2752,12 +3488,34 @@ "node": ">=14" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -2767,6 +3525,28 @@ "node": ">=8" } }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -2785,6 +3565,15 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2814,6 +3603,21 @@ "node": ">= 4.0.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -2839,6 +3643,83 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -2853,20 +3734,43 @@ "npm": ">=6" } }, - "node_modules/babel-plugin-macros/node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" }, - "bin": { - "resolve": "bin/resolve" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@babel/core": "^7.0.0" } }, "node_modules/bail": { @@ -2979,6 +3883,27 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -3012,6 +3937,12 @@ "node": "*" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -3024,6 +3955,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -3060,6 +4000,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -3069,9 +4018,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001607", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001607.tgz", - "integrity": "sha512-WcvhVRjXLKFB/kmOFVwELtMxyhq3iM/MvmXcyCe2PNf166c39mptscOc/45TTS96n2gpNV2z7+NakArTWZCQ3w==", + "version": "1.0.30001612", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz", + "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==", "dev": true, "funding": [ { @@ -3103,6 +4052,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3131,6 +4098,15 @@ "node": ">=8" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3167,6 +4143,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -3191,6 +4179,12 @@ "node": ">=8" } }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -3253,6 +4247,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", @@ -3261,6 +4269,22 @@ "node": ">=6" } }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3329,8 +4353,14 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, - "node_modules/convert-source-map": { - "version": "1.9.0", + "node_modules/confbox": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" }, @@ -3354,6 +4384,27 @@ "node": ">=10" } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/cross-fetch": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", @@ -3395,15 +4446,27 @@ "postcss-value-parser": "^4.0.2" } }, + "node_modules/cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cypress": { - "version": "13.7.2", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.7.2.tgz", - "integrity": "sha512-FF5hFI5wlRIHY8urLZjJjj/YvfCBrRpglbZCLr/cYcL9MdDe0+5usa8kTIrDHthlEc9lwihbkb5dmwqBDNS2yw==", + "version": "13.8.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.8.1.tgz", + "integrity": "sha512-Uk6ovhRbTg6FmXjeZW/TkbRM07KPtvM5gah1BIMp4Y2s+i/NMxgaLw0+PbYTOdw1+egE0FP3mWRiGcRkjjmhzA==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -3475,6 +4538,104 @@ "node": ">=0.10" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dayjs": { "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", @@ -3507,6 +4668,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -3519,12 +4686,47 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3542,6 +4744,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3558,6 +4777,15 @@ "node": ">=6" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3570,6 +4798,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3608,6 +4845,12 @@ "resolved": "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz", "integrity": "sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==" }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -3619,9 +4862,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.730", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.730.tgz", - "integrity": "sha512-oJRPo82XEqtQAobHpJIR3zW5YO3sSRRkPz2an4yxi1UvqhsGm54vR/wzTFV74a3soDOJ8CKW7ajOOX5ESzddwg==", + "version": "1.4.746", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.746.tgz", + "integrity": "sha512-jeWaIta2rIG2FzHaYIhSuVWqC6KJYo7oSBX4Jv7g+aVujKztfvdpf+n6MGwZdC5hQXbax4nntykLH2juIQrfPg==", "dev": true }, "node_modules/emitter-component": { @@ -3632,6 +4875,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3660,6 +4915,18 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3668,6 +4935,66 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", @@ -3689,6 +5016,49 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", @@ -4028,6 +5398,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", @@ -4070,6 +5453,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4079,6 +5471,21 @@ "node": ">=0.10.0" } }, + "node_modules/event-stream": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/event-stream/-/event-stream-3.3.4.tgz", + "integrity": "sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==", + "dev": true, + "dependencies": { + "duplexer": "~0.1.1", + "from": "~0", + "map-stream": "~0.1.0", + "pause-stream": "0.0.11", + "split": "0.3", + "stream-combiner": "~0.0.4", + "through": "~2.3.1" + } + }, "node_modules/eventemitter2": { "version": "6.4.7", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", @@ -4120,6 +5527,31 @@ "node": ">=4" } }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4181,6 +5613,15 @@ "reusify": "^1.0.4" } }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -4298,6 +5739,15 @@ } } }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -4320,6 +5770,12 @@ "node": ">= 6" } }, + "node_modules/from": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/from/-/from-0.1.7.tgz", + "integrity": "sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==", + "dev": true + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -4363,26 +5819,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", "dev": true, "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" }, "engines": { "node": ">= 0.4" @@ -4391,10 +5837,74 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, "dependencies": { "pump": "^3.0.0" @@ -4406,6 +5916,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/getos": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", @@ -4486,6 +6013,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -4593,6 +6135,15 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4638,6 +6189,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4700,6 +6266,30 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -4718,6 +6308,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-signature": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", @@ -4732,6 +6335,19 @@ "node": ">=0.10" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", @@ -4782,6 +6398,18 @@ "cross-fetch": "4.0.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4831,6 +6459,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4878,6 +6525,20 @@ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz", "integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g==" }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -4900,11 +6561,55 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-builtin-module": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", @@ -4920,6 +6625,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-ci": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", @@ -4943,6 +6660,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -4970,6 +6717,15 @@ "node": ">=8" } }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5007,6 +6763,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5016,6 +6784,21 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -5036,23 +6819,105 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true }, "node_modules/is-unicode-supported": { "version": "0.1.0", @@ -5066,6 +6931,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -5083,12 +6960,696 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz", + "integrity": "sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-changed-files/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/jest-changed-files/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-changed-files/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", "dev": true }, + "node_modules/joi": { + "version": "17.13.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.1.tgz", + "integrity": "sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg==", + "dev": true, + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5112,6 +7673,80 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "dev": true }, + "node_modules/jsdom": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", + "integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==", + "dev": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.7", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -5130,6 +7765,12 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5251,6 +7892,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", @@ -5260,6 +7910,15 @@ "node": "> 0.8" } }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5313,13 +7972,66 @@ } } }, - "node_modules/listr2/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", "dev": true, "dependencies": { - "tslib": "^2.1.0" + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-json-file/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/local-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", + "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "dev": true, + "dependencies": { + "mlly": "^1.4.2", + "pkg-types": "^1.0.3" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" } }, "node_modules/locate-path": { @@ -5343,6 +8055,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5440,6 +8158,15 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -5452,6 +8179,62 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "0.30.10", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", + "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/magicast": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.4.tgz", + "integrity": "sha512-TyDF/Pn36bBji9rWKHlZe+PZb6Mx5V8IHCSxk7X4aljM4e/vyDvZZYwHewdVaqiA0nb3ghfHU/6AUpDxWoER2Q==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.24.4", + "@babel/types": "^7.24.0", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/map-stream": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", + "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", + "dev": true + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", @@ -5660,6 +8443,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -6072,111 +8864,328 @@ "picomatch": "^2.3.1" }, "engines": { - "node": ">=8.6" + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.6.1.tgz", + "integrity": "sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.0.3", + "ufo": "^1.3.2" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm-run-all/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-all/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" } }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" + "node_modules/npm-run-all/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/npm-run-all/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/npm-run-all/node_modules/cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, "dependencies": { - "mime-db": "1.52.0" + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" }, "engines": { - "node": ">= 0.6" + "node": ">=4.8" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "node_modules/npm-run-all/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, "engines": { - "node": ">=6" + "node": ">=0.8.0" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/npm-run-all/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, "engines": { - "node": "*" + "node": ">=4" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "node_modules/npm-run-all/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": ">=4" } }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/npm-run-all/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, "bin": { - "nanoid": "bin/nanoid.cjs" + "semver": "bin/semver" + } + }, + "node_modules/npm-run-all/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "dependencies": { + "shebang-regex": "^1.0.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": ">=0.10.0" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "node_modules/npm-run-all/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/npm-run-all/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, "dependencies": { - "whatwg-url": "^5.0.0" + "has-flag": "^3.0.0" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "node": ">=4" } }, - "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true + "node_modules/npm-run-all/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } }, "node_modules/npm-run-path": { "version": "4.0.1", @@ -6190,6 +9199,12 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.9.tgz", + "integrity": "sha512-2f3F0SEEer8bBu0dsNCFF50N0cTThV1nWFYcEYFZttdW0lDAoybv9cQoK7X7/68Z89S7FoRrVjP1LPX4XRf9vg==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6207,6 +9222,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6299,6 +9341,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -6332,6 +9383,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6372,6 +9435,30 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/pause-stream": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", + "integrity": "sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==", + "dev": true, + "dependencies": { + "through": "~2.3" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -6401,6 +9488,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pify": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", @@ -6410,6 +9509,99 @@ "node": ">=0.10.0" } }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.0.tgz", + "integrity": "sha512-/RpmvKdxKf8uILTtoOhAgf30wYbP2Qw+L9p3Rvshx1JZVX+XQNZQFjlbmGHEGIm4CkVPlSn+NXmIM8+9oWQaSA==", + "dev": true, + "dependencies": { + "confbox": "^0.1.7", + "mlly": "^1.6.1", + "pathe": "^1.1.2" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6459,22 +9651,48 @@ "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/process": { @@ -6491,6 +9709,19 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6520,6 +9751,21 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/ps-tree": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.2.0.tgz", + "integrity": "sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==", + "dev": true, + "dependencies": { + "event-stream": "=3.3.4" + }, + "bin": { + "ps-tree": "bin/ps-tree.js" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -6545,6 +9791,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/qs": { "version": "6.10.4", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", @@ -6715,11 +9977,64 @@ "react-dom": ">=16.6.0" } }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/read-pkg/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -6760,6 +10075,15 @@ "throttleit": "^1.0.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -6771,6 +10095,43 @@ "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz", "integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==" }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6779,6 +10140,15 @@ "node": ">=4" } }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -6857,6 +10227,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6880,6 +10256,39 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -6900,12 +10309,41 @@ } ] }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -6946,6 +10384,21 @@ "node": ">= 0.4" } }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -6977,6 +10430,15 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -6995,12 +10457,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7040,6 +10514,25 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -7049,6 +10542,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/spdx-exceptions": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", @@ -7071,31 +10584,150 @@ "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", "dev": true }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "node_modules/split": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/split/-/split-0.3.3.tgz", + "integrity": "sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==", + "dev": true, + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/start-server-and-test": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.0.3.tgz", + "integrity": "sha512-QsVObjfjFZKJE6CS6bSKNwWZCKBG6975/jKRPPGFfFh+yOQglSeGXiNWjzgQNXdphcBI9nXbyso9tPfX4YAUhg==", "dev": true, "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" + "arg": "^5.0.2", + "bluebird": "3.7.2", + "check-more-types": "2.24.0", + "debug": "4.3.4", + "execa": "5.1.1", + "lazy-ass": "1.6.0", + "ps-tree": "1.2.0", + "wait-on": "7.2.0" }, "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" + "server-test": "src/bin/start.js", + "start-server-and-test": "src/bin/start.js", + "start-test": "src/bin/start.js" }, "engines": { - "node": ">=0.10.0" + "node": ">=16" + } + }, + "node_modules/start-server-and-test/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/start-server-and-test/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/start-server-and-test/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" } }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, "node_modules/stream": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", @@ -7104,6 +10736,15 @@ "emitter-component": "^1.1.1" } }, + "node_modules/stream-combiner": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stream-combiner/-/stream-combiner-0.0.4.tgz", + "integrity": "sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==", + "dev": true, + "dependencies": { + "duplexer": "~0.1.1" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -7117,6 +10758,19 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7131,6 +10785,73 @@ "node": ">=8" } }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7156,6 +10877,15 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -7177,6 +10907,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.0.tgz", + "integrity": "sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz", + "integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==", + "dev": true + }, "node_modules/style-to-object": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.6.tgz", @@ -7271,6 +11019,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -7292,6 +11060,30 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tinybench": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", + "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -7301,6 +11093,12 @@ "node": ">=14.14" } }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -7381,6 +11179,49 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-jest": { + "version": "29.1.2", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.2.tgz", + "integrity": "sha512-br6GJoH/WUX4pu7FbZXuWGKGNDuU7b8Uj77g/Sp7puZV6EXzuByl6JrECvm0MzVzSTkSHWTihsXt+5XYER5b+g==", + "dev": true, + "dependencies": { + "bs-logger": "0.x", + "fast-json-stable-stringify": "2.x", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "4.x", + "make-error": "1.x", + "semver": "^7.5.3", + "yargs-parser": "^21.0.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", @@ -7416,6 +11257,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -7428,10 +11278,83 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.4.tgz", - "integrity": "sha512-dGE2Vv8cpVvw28v8HCPqyb08EzbBURxDpuhJvTrusShUfGnhHBafDsLdS1EhhxyL6BJQE+2cT3dDPAv+MQ6oLw==", + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -7441,12 +11364,32 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", + "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "dev": true + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "optional": true + "dev": true }, "node_modules/unified": { "version": "11.0.4", @@ -7610,8 +11553,48 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, - "bin": { - "uuid": "dist/bin/uuid" + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", + "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" } }, "node_modules/vfile": { @@ -7696,6 +11679,28 @@ } } }, + "node_modules/vite-node": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.5.2.tgz", + "integrity": "sha512-Y8p91kz9zU+bWtF7HGt6DVw2JbhyuB2RlZix3FPYAYmUyZ3n7iTp8eSyLyY6sxtPegvxQtmlTMhfPhUfCUF93A==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite/node_modules/postcss": { "version": "8.4.38", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", @@ -7724,6 +11729,205 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/vitest": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.5.2.tgz", + "integrity": "sha512-l9gwIkq16ug3xY7BxHwcBQovLZG75zZL0PlsiYQbf76Rz6QGs54416UWMtC0jXeihvHvcHrf2ROEjkQRVpoZYw==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.5.2", + "@vitest/runner": "1.5.2", + "@vitest/snapshot": "1.5.2", + "@vitest/spy": "1.5.2", + "@vitest/utils": "1.5.2", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.5.2", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.5.2", + "@vitest/ui": "1.5.2", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/vitest/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/vitest/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/vitest/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -7733,12 +11937,73 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wait-on": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", + "integrity": "sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==", + "dev": true, + "dependencies": { + "axios": "^1.6.1", + "joi": "^17.11.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.1" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -7764,6 +12029,57 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7787,6 +12103,64 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -7801,6 +12175,33 @@ "node": ">= 6" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c03f123..cb56ab96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,12 @@ "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview --port 5173", - "cypress:test": "cypress run", - "test": "npm run cypress:test" + "cy:test": "start-server-and-test dev http://localhost:5173 cy:run", + "cy:run": "cypress run", + "cy:open": "cypress open", + "vi:test": "vitest run", + "coverage": "vitest run --coverage", + "test": "npm-run-all -p vi:test cy:test" }, "dependencies": { "@emotion/react": "^11.11.4", @@ -46,7 +50,8 @@ "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "@vitejs/plugin-react": "^4.2.1", - "cypress": "^13.7.0", + "@vitest/coverage-v8": "^1.5.2", + "cypress": "^13.8.1", "eslint": "^8.56.0", "eslint-plugin-jsdoc": "^48.1.0", "eslint-plugin-react-hooks": "^4.6.0", @@ -55,9 +60,15 @@ "i18next": "^23.10.1", "i18next-browser-languagedetector": "^7.2.1", "i18next-http-backend": "^2.5.0", + "jest": "^29.7.0", + "jsdom": "^24.0.0", + "npm-run-all": "^4.1.5", "react-i18next": "^14.1.0", "scheduler": "^0.23.0", - "typescript": "^5.2.2", - "vite": "^5.1.7" + "start-server-and-test": "^2.0.3", + "ts-jest": "^29.1.2", + "typescript": "^5.4.5", + "vite": "^5.1.7", + "vitest": "^1.5.2" } } diff --git a/frontend/tests/unit/header.test.tsx b/frontend/tests/unit/header.test.tsx new file mode 100644 index 00000000..f4e6fdd9 --- /dev/null +++ b/frontend/tests/unit/header.test.tsx @@ -0,0 +1,3 @@ +import { test } from "vitest"; + +test.todo("Header test"); diff --git a/frontend/tests/unit/homepage.test.tsx b/frontend/tests/unit/homepage.test.tsx new file mode 100644 index 00000000..bea65bd5 --- /dev/null +++ b/frontend/tests/unit/homepage.test.tsx @@ -0,0 +1,3 @@ +import { test } from "vitest"; + +test.todo("Homepage test"); diff --git a/frontend/tests/unit/type-tests/me.test.tsx b/frontend/tests/unit/type-tests/me.test.tsx new file mode 100644 index 00000000..305172d9 --- /dev/null +++ b/frontend/tests/unit/type-tests/me.test.tsx @@ -0,0 +1,5 @@ +import { describe, test } from "vitest"; + +describe("Me is properly defined", () => { + test.todo("Me.role is string"); +}); diff --git a/frontend/tests/utils/api.ts b/frontend/tests/utils/api.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/tests/utils/data.ts b/frontend/tests/utils/data.ts new file mode 100644 index 00000000..8ad42080 --- /dev/null +++ b/frontend/tests/utils/data.ts @@ -0,0 +1,7 @@ +export const courses = [{}]; + +export const projects = [{}]; + +export const submissions = [{}]; + +export const users = [{}]; diff --git a/frontend/tests/utils/utils.ts b/frontend/tests/utils/utils.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 3bde694e..9a5bee7f 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -21,7 +21,10 @@ "noFallthroughCasesInSwitch": true, "noImplicitAny": true, "noImplicitThis": true, - "strictNullChecks": true + "strictNullChecks": true, + + /* Testing */ + "types": ["vitest/globals"] }, "include": [ "src", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5a33944a..4e0a261b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,12 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + }, }) From e97d2b42ef63e9e4d8da42c1ba01084ec021583f Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Mon, 20 May 2024 15:52:57 +0200 Subject: [PATCH 349/377] fix JWT refresh to use database (#383) --- backend/project/init_auth.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/project/init_auth.py b/backend/project/init_auth.py index 5d4d6356..d2d6adf8 100644 --- a/backend/project/init_auth.py +++ b/backend/project/init_auth.py @@ -5,6 +5,8 @@ from flask_jwt_extended import get_jwt, get_jwt_identity,\ create_access_token, set_access_cookies +from .utils.models.user_utils import get_user +from .models.user import Role def auth_init(jwt, app): """ @@ -43,11 +45,13 @@ def refresh_expiring_jwts(response): now = datetime.now(timezone.utc) target_timestamp = datetime.timestamp(now + timedelta(minutes=30)) if target_timestamp > exp_timestamp: + uid = get_jwt_identity() + user = get_user(uid) access_token = create_access_token( - identity=get_jwt_identity(), + identity=uid, additional_claims= - {"is_admin":get_jwt()["is_admin"], - "is_teacher":get_jwt()["is_teacher"]} + {"is_admin":user.role==Role.ADMIN, + "is_teacher":user.role==Role.TEACHER} ) set_access_cookies(response, access_token) return response From 55974242a6c058746443b009aaa4a8d5d153a2af Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Mon, 20 May 2024 16:14:36 +0200 Subject: [PATCH 350/377] Update backend README (#357) * added table to frontend readme * added .env vars to the README * added readme vars * changes * added explanation * removed changes to frontend readme to exclude it from this pr * remove code verifier * added JWT application * POST_HOST name * added rfc link * added microsoft admin link * updated link --- backend/README.md | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/backend/README.md b/backend/README.md index b7cd5ee4..d9f0dc48 100644 --- a/backend/README.md +++ b/backend/README.md @@ -27,14 +27,18 @@ the regular base application. The project requires a couple of environment variables to run, if you want to develop on this codebase. Setting values for these variables can be done with a method to your own liking. -| Variable | Description | -|-------------------|----------------------------------------------------------------| -| DB_HOST | Url of where the database is located | -| POSTGRES_USER | Name of the user, needed to login to the postgres database | -| POSTGRES_PASSWORD | Password of the user, needed to login to the postgres database | -| POSTGRES_HOST | IP adress of the postgres database | -| POSTGRES_DB | Name of the postgres database | -| API_HOST | Location of the API root | +| Variable | Description | +|----------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| POSTGRES_USER | Name of the user, needed to login to the postgres database | +| POSTGRES_PASSWORD | Password of the user, needed to login to the postgres database | +| POSTGRES_HOST | Location of the postgres database | +| POSTGRES_DB | Name of the postgres database | +| API_HOST | Location of the API root | +| CLIENT_ID | [Client id](https://learn.microsoft.com/nl-nl/entra/identity-platform/v2-protocols) | +| CLIENT_SECRET | Client's secret is your personal secret key for authentication, this can be found at the [Entra ID admin center](https://learn.microsoft.com/en-us/purview/sit-defn-azure-ad-client-secret) | +| JWT_SECRET_KEY | JWT secret key is the key used to encode the JWT's and should be kept secret, because otherwise everyone can create "valid" JWT's for our application. Variable should be a random 32 characters long string, if you need more information please refer to the [RDF documentation](https://www.rfc-editor.org/rfc/rfc4868#page-3) | +| TENANT_ID | [Tenant id](https://learn.microsoft.com/nl-nl/entra/fundamentals/whatis), an ID that is used to identify yourself to the microsoft servic | +| HOMEPAGE_URL | URL of where the website's homepage is located | All the variables except the last one are for the database setup, these are needed to make a connection with the database. From de0424133e16ff669a5fb31b60307cdf95767f2e Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Wed, 22 May 2024 09:01:22 +0200 Subject: [PATCH 351/377] fix requirements/docker dependency bug (#392) --- backend/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 529099ca..b9d38d15 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,7 +9,7 @@ psycopg2-binary docker pytest~=8.0.1 SQLAlchemy~=2.0.27 -requests>=2.31.0 +requests<=2.31.0 waitress flask_swagger_ui -flask_executor \ No newline at end of file +flask_executor From e8b2862e2eece61b394f5dc40d2006586d514c8c Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 23 May 2024 09:27:02 +0200 Subject: [PATCH 352/377] Fix #265 (#353) --- frontend/package-lock.json | 3 +- .../components/Courses/AllCoursesTeacher.tsx | 70 +++++++++++++------ .../src/components/Courses/CourseUtils.tsx | 4 +- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9143b918..d060e871 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -61,8 +61,7 @@ "ts-jest": "^29.1.2", "typescript": "^5.4.5", "vite": "^5.1.7", - "vitest": "^1.5.2", - "wait-on": "^7.2.0" + "vitest": "^1.5.2" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/frontend/src/components/Courses/AllCoursesTeacher.tsx b/frontend/src/components/Courses/AllCoursesTeacher.tsx index a0e15145..fb3a3b62 100644 --- a/frontend/src/components/Courses/AllCoursesTeacher.tsx +++ b/frontend/src/components/Courses/AllCoursesTeacher.tsx @@ -1,30 +1,42 @@ -import { Button, Dialog, DialogActions, DialogTitle, FormControl, FormHelperText, Grid, Input, InputLabel } from "@mui/material"; +import { + Button, + Dialog, + DialogActions, + DialogTitle, + FormControl, + FormHelperText, + Grid, + Input, + InputLabel, +} from "@mui/material"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { SideScrollableCourses } from "./CourseUtilComponents"; -import {Course, callToApiToCreateCourse, ProjectDetail} from "./CourseUtils"; +import { Course, callToApiToCreateCourse, ProjectDetail } from "./CourseUtils"; import { Title } from "../Header/Title"; import { useLoaderData } from "react-router-dom"; +import { Me } from "../../types/me"; /** * @returns A jsx component representing all courses for a teacher */ export function AllCoursesTeacher(): JSX.Element { const [open, setOpen] = useState(false); - const loader = useLoaderData() as { + const { courses, projects, me } = useLoaderData() as { courses: Course[]; + me: Me; projects: { [courseId: string]: ProjectDetail[] }; }; - const courses = loader.courses; - const projects = loader.projects; - const [courseName, setCourseName] = useState(''); - const [error, setError] = useState(''); + const [courseName, setCourseName] = useState(""); + const [error, setError] = useState(""); const navigate = useNavigate(); - const { t } = useTranslation('translation', { keyPrefix: 'allCoursesTeacher' }); + const { t } = useTranslation("translation", { + keyPrefix: "allCoursesTeacher", + }); const handleClickOpen = () => { setOpen(true); @@ -36,14 +48,14 @@ export function AllCoursesTeacher(): JSX.Element { const handleInputChange = (event: React.ChangeEvent) => { setCourseName(event.target.value); - setError(''); // Clearing error message when user starts typing + setError(""); // Clearing error message when user starts typing }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // Prevents the default form submission behaviour if (!courseName.trim()) { - setError(t('emptyCourseNameError')); + setError(t("emptyCourseNameError")); return; } @@ -52,14 +64,21 @@ export function AllCoursesTeacher(): JSX.Element { }; return ( <> - - - + + + - {t('courseForm')} + {t("courseForm")} - {t('courseName')} + {t("courseName")} - {error && {error}} + {error && ( + {error} + )} - - + + - - - + {me && me.role === "TEACHER" && ( + + + + )} ); -} \ No newline at end of file +} diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 68c81383..118f8b88 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -1,6 +1,7 @@ import { NavigateFunction, Params } from "react-router-dom"; import { authenticatedFetch } from "../../utils/authenticated-fetch"; import { Me } from "../../types/me"; +import { fetchMe } from "../../utils/fetches/FetchMe"; export interface Course { course_id: string; @@ -125,11 +126,12 @@ export const dataLoaderCourses = async () => { const courses = await fetchData(`courses`); const projects = await fetchProjectsCourse(courses); + const me = await fetchMe(); for( const c of courses){ const teacher = await fetchData(`users/${c.teacher}`) c.teacher = teacher.display_name } - return {courses, projects} + return {courses, projects, me} }; /** From 9f4237f6396f120fbb82e1a8783037363850ca5e Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 12:47:57 +0200 Subject: [PATCH 353/377] removed edit link (#396) --- documentation/docusaurus.config.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/documentation/docusaurus.config.ts b/documentation/docusaurus.config.ts index bf28738f..1396432a 100644 --- a/documentation/docusaurus.config.ts +++ b/documentation/docusaurus.config.ts @@ -35,10 +35,6 @@ const config: Config = { { docs: { sidebarPath: './sidebars.ts', - // Please change this to your repo. - // Remove this to remove the "edit this page" links. - editUrl: - 'https://github.com/SELab-2/UGent-3', }, theme: { customCss: './src/css/custom.css', From fe979f5d83b89a8d8d1f4be005862ea42a76439c Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 13:05:12 +0200 Subject: [PATCH 354/377] added user guide links (#397) * added user guide links * added specific runner link * small styling issue --- frontend/public/locales/en/projectformTranslation.json | 2 +- frontend/src/components/ProjectForm/ProjectForm.tsx | 2 +- frontend/src/components/ProjectForm/RunnerSelecter.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/public/locales/en/projectformTranslation.json b/frontend/public/locales/en/projectformTranslation.json index bdaa2dcf..fd48043a 100644 --- a/frontend/public/locales/en/projectformTranslation.json +++ b/frontend/public/locales/en/projectformTranslation.json @@ -17,7 +17,7 @@ "runnerComponent": { "testWarning": "No appropriate test file found, can't upload project", "clearSelected": "Clear Selection", - "tooltipRunner": "If you're having trouble figuring out the runner please refer to the docs", + "tooltipRunner": "If you're having trouble figuring out the runner please refer to the ", "userDocs": "runner user docs" }, "dragAndDrop": { diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index 39f81dc0..ef66d522 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -372,7 +372,7 @@ export default function ProjectForm() { handleFileUpload2(file)} regexRequirements={[]} /> - {t("fileInfo")}: {t("userDocs")}}> + {t("fileInfo")}: {t("userDocs")}}> diff --git a/frontend/src/components/ProjectForm/RunnerSelecter.tsx b/frontend/src/components/ProjectForm/RunnerSelecter.tsx index d91dc2f1..73804945 100644 --- a/frontend/src/components/ProjectForm/RunnerSelecter.tsx +++ b/frontend/src/components/ProjectForm/RunnerSelecter.tsx @@ -66,7 +66,7 @@ export default function RunnerSelecter({ handleSubmit, runner, containsDocker, c {t("clearSelected")} - {t("tooltipRunner")}: {t("userDocs")}}> + {t("tooltipRunner")} {t("userDocs")}}> From 37606d40b7c99bd18cf3720f24bbe0635476fbd5 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 13:12:13 +0200 Subject: [PATCH 355/377] Add overview button to admin page of projects (#384) * changes bekijken * beaucoup * edit title and description functionality * linter :nerd: * frontend linter --------- Co-authored-by: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> --- .../endpoints/projects/endpoint_parser.py | 4 +- frontend/public/locales/en/translation.json | 8 +- frontend/public/locales/nl/translation.json | 4 +- .../components/DeadlineView/DeadlineGrid.tsx | 43 +++ .../components/ProjectForm/ProjectForm.tsx | 26 +- .../pages/project/projectView/ProjectView.tsx | 282 +++++++++++++++--- 6 files changed, 303 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/DeadlineView/DeadlineGrid.tsx diff --git a/backend/project/endpoints/projects/endpoint_parser.py b/backend/project/endpoints/projects/endpoint_parser.py index 48ef4874..895658be 100644 --- a/backend/project/endpoints/projects/endpoint_parser.py +++ b/backend/project/endpoints/projects/endpoint_parser.py @@ -28,7 +28,7 @@ help='Projects visibility for students', location="form" ) -parser.add_argument("archived", type=bool, help='Projects', location="form") +parser.add_argument("archived", type=str, help='Projects', location="form") parser.add_argument( "regex_expressions", type=str, @@ -61,6 +61,8 @@ def parse_project_params(): ) ) result_dict[key] = new_deadlines + elif "archived" == key: + result_dict[key] = value == "true" else: result_dict[key] = value diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 86ed53b3..e6b5c6e5 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -52,7 +52,7 @@ "cancel": "Cancel", "create": "Create", "activeCourses": "Active Courses", - "archivedCourses":"Archived Courses" + "archivedCourses":"Archived Courses" }, "courseForm": { "courseName": "Course Name", @@ -72,7 +72,9 @@ "running": "Running", "submitTime": "Time submitted", "status": "Status" - } + }, + "projectOverview": "Overview", + "archive": "Archive" }, "time": { "yearsAgo": "years ago", @@ -149,4 +151,4 @@ "no_projects": "There are no projects here.", "new_project": "New Project" } -} +} \ No newline at end of file diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 0b693055..7715aecd 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -109,7 +109,9 @@ "running": "Aan het uitvoeren", "submitTime": "Indientijd", "status": "Status" - } + }, + "projectOverview": "Overzicht", + "archive": "Archiveer" }, "time": { "yearsAgo": "jaren geleden", diff --git a/frontend/src/components/DeadlineView/DeadlineGrid.tsx b/frontend/src/components/DeadlineView/DeadlineGrid.tsx new file mode 100644 index 00000000..154442da --- /dev/null +++ b/frontend/src/components/DeadlineView/DeadlineGrid.tsx @@ -0,0 +1,43 @@ +import {Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@mui/material"; +import {Deadline} from "../../types/deadline.ts"; +import {useTranslation} from "react-i18next"; + +interface Props { + deadlines: Deadline[]; + minWidth: number +} + +/** + * @returns grid that displays deadlines in a grid + */ +export default function DeadlineGrid({deadlines, minWidth}: Props) { + + const { t } = useTranslation('translation', { keyPrefix: 'projectForm' }); + + return ( + + + + + {t("deadline")} + {t("description")} + + + + {deadlines.length === 0 ? ( // Check if deadlines is empty + + {t("noDeadlinesPlaceholder")} + + ) : ( + deadlines.map((deadline, index) => ( + + {deadline.deadline} + {deadline.description} + + )) + )} + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ProjectForm/ProjectForm.tsx b/frontend/src/components/ProjectForm/ProjectForm.tsx index ef66d522..46f8863a 100644 --- a/frontend/src/components/ProjectForm/ProjectForm.tsx +++ b/frontend/src/components/ProjectForm/ProjectForm.tsx @@ -31,6 +31,7 @@ import AdvancedRegex from "./AdvancedRegex.tsx"; import RunnerSelecter from "./RunnerSelecter.tsx"; import { authenticatedFetch } from "../../utils/authenticated-fetch.ts"; import i18next from "i18next"; +import DeadlineGrid from "../DeadlineView/DeadlineGrid.tsx"; interface Course { course_id: string; @@ -332,30 +333,7 @@ export default function ProjectForm() { deadlines={[]} onChange={(deadlines: Deadline[]) => handleDeadlineChange(deadlines)} editable={true} /> - - - - - {t("deadline")} - {t("description")} - - - - {deadlines.length === 0 ? ( // Check if deadlines is empty - - {t("noDeadlinesPlaceholder")} - - ) : ( - deadlines.map((deadline, index) => ( - - {deadline.deadline} - {deadline.description} - - )) - )} - -
-
+
diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 209279de..8b1f8dd8 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -1,21 +1,32 @@ import { + Box, + Button, Card, CardContent, CardHeader, Container, - Grid, - Link, + Fade, + Grid, IconButton, Stack, + TextField, Typography, } from "@mui/material"; -import { useEffect, useState } from "react"; +import {useCallback, useEffect, useState} from "react"; import Markdown from "react-markdown"; -import { useParams } from "react-router-dom"; +import {useLocation, useNavigate, useParams} from "react-router-dom"; import SubmissionCard from "./SubmissionCard"; import { Course } from "../../../types/course"; import { Title } from "../../../components/Header/Title"; import { authenticatedFetch } from "../../../utils/authenticated-fetch"; import i18next from "i18next"; +import {useTranslation} from "react-i18next"; +import {Me} from "../../../types/me.ts"; +import {fetchMe} from "../../../utils/fetches/FetchMe.ts"; +import DeadlineGrid from "../../../components/DeadlineView/DeadlineGrid.tsx"; +import {Deadline} from "../../../types/deadline.ts"; +import EditIcon from '@mui/icons-material/Edit'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; const API_URL = import.meta.env.VITE_APP_API_HOST; @@ -23,6 +34,7 @@ interface Project { title: string; description: string; regex_expressions: string[]; + archived: string; } /** @@ -31,17 +43,75 @@ interface Project { * and submissions of the current user for that project */ export default function ProjectView() { + + const location = useLocation(); + const [me, setMe] = useState(null); + + const { t } = useTranslation('translation', { keyPrefix: 'projectView' }); + const { projectId } = useParams<{ projectId: string }>(); const [projectData, setProjectData] = useState(null); const [courseData, setCourseData] = useState(null); const [assignmentRawText, setAssignmentRawText] = useState(""); + const [deadlines, setDeadlines] = useState([]); + const [alertVisibility, setAlertVisibility] = useState(false) + const [edit, setEdit] = useState(false); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); - useEffect(() => { + const navigate = useNavigate() + const deleteProject = () => { + authenticatedFetch(`${API_URL}/projects/${projectId}`, { + method: "DELETE" + }); + navigate('/projects'); + } + + const patchTitleAndDescription = async () => { + setEdit(false); + const formData = new FormData(); + formData.append('title', title); + formData.append('description', description); + + const response = await authenticatedFetch(`${API_URL}/projects/${projectId}`, { + method: "PATCH", + body: formData + }); + + // Check if the response is ok (status code 2xx) + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + updateProject(); + } + + const discardEditTitle = () => { + const title = projectData?.title; + setEdit(false); + if (title) + setTitle(title); + + if (projectData?.description) + setDescription(projectData?.description); + } + + const updateProject = useCallback(async () => { authenticatedFetch(`${API_URL}/projects/${projectId}`).then((response) => { if (response.ok) { response.json().then((data) => { const projectData = data["data"]; setProjectData(projectData); + setTitle(projectData.title); + setDescription(projectData.description); + + const transformedDeadlines = projectData.deadlines.map((deadlineArray: string[]): Deadline => ({ + description: deadlineArray[0], + deadline: deadlineArray[1] + })); + + setDeadlines(transformedDeadlines); + authenticatedFetch( `${API_URL}/courses/${projectData.course_id}` ).then((response) => { @@ -54,6 +124,23 @@ export default function ProjectView() { }); } }); + }, [projectId]); + + const archiveProject = async () => { + const newArchived = !projectData?.archived; + const formData = new FormData(); + formData.append('archived', newArchived.toString()); + + await authenticatedFetch(`${API_URL}/projects/${projectId}`, { + method: "PATCH", + body: formData + }) + + await updateProject(); + } + + useEffect(() => { + updateProject(); authenticatedFetch( `${API_URL}/projects/${projectId}/assignment?lang=${i18next.resolvedLanguage}` @@ -62,56 +149,181 @@ export default function ProjectView() { response.text().then((data) => setAssignmentRawText(data)); } }); - }, [projectId]); + + fetchMe().then((data) => { + setMe(data); + }); + + }, [projectId, updateProject]); if (!projectId) return null; return ( - - - + + + {projectData && ( <CardHeader color="secondary" - title={projectData.title} + title={ + <Box + display="flex" + justifyContent="space-between" + alignItems="center" + > + { + !edit && <>{projectData.title}</> + } + { + edit && <><TextField id="edit-title" label="title" variant="outlined" size="small" defaultValue={title} onChange={(event) => setTitle(event.target.value)}/></> + } + {courseData && ( + <Button variant="outlined" type="link" href={`/${i18next.resolvedLanguage}/courses/${courseData.course_id}`}> + {courseData.name} + </Button> + )} + </Box> + } subheader={ - <> + <Box position="relative" height="100%" sx={{marginTop: "10px"}}> <Stack direction="row" spacing={2}> - <Typography>{projectData.description}</Typography> + { + !edit && <><Typography>{projectData.description}</Typography></> + } + { + edit && <><TextField id="edit-description" label="description" variant="outlined" size="small" defaultValue={description} onChange={(event) => setDescription(event.target.value)}/></> + } <Typography flex="1" /> - {courseData && ( - <Link href={`/${i18next.resolvedLanguage}/courses/${courseData.course_id}`}> - <Typography>{courseData.name}</Typography> - </Link> - )} </Stack> - </> + </Box> } /> <CardContent> <Markdown>{assignmentRawText}</Markdown> + <Box + display="flex" + alignItems="flex-end" + justifyContent="end" + > + { + edit && ( + <> + <IconButton onClick={patchTitleAndDescription}> + <CheckIcon /> + </IconButton> + <IconButton onClick={discardEditTitle}> + <CloseIcon /> + </IconButton> + </> + ) + } + { + !edit && ( + <IconButton onClick={() => setEdit(true)}> + <EditIcon /> + </IconButton> + ) + } + </Box> </CardContent> </Card> )} - </Container> - </Grid> - <Grid item sm={12}> - <Container> - <SubmissionCard - regexRequirements={projectData ? projectData.regex_expressions : []} - submissionUrl={`${API_URL}/submissions`} - projectId={projectId} - /> - </Container> + <Box marginTop="2rem"> + <SubmissionCard + regexRequirements={projectData ? projectData.regex_expressions : []} + submissionUrl={`${API_URL}/submissions`} + projectId={projectId} + /> + </Box> + {me && me.role == "TEACHER" && ( + <Box + width="100%"> + <Box display="flex" + flexDirection="row" + sx={{ + justifyContent: "space-around" + }} + pt={2} + width="100%" + > + <Box + display="flex" + flexDirection="row" + pt={2} + width="100%" + > + <Button + type="link" + variant="contained" + href={location.pathname + "/overview"} + sx={{marginRight: 1}} + > + {t("projectOverview")} + </Button> + <Button + variant="contained" + onClick={archiveProject} + > + {t("archive")} + </Button> + </Box> + <Box + display="flex" + flexDirection="row-reverse" + pt={2} + width="100%"> + <Button variant="contained" color="error" onClick={() => setAlertVisibility(true)}> + Delete + </Button> + </Box> + </Box> + <Box display="flex" style={{width: "100%" }}> + <div style={{flexGrow: 1}} /> + <Fade + style={{width: "fit-content"}} + in={alertVisibility} + timeout={{ enter: 1000, exit: 1000 }} + addEndListener={() => { + setTimeout(() => { + setAlertVisibility(false); + }, 4000); + }} + > + <Box sx={{ border: 1, p: 1, bgcolor: 'background.paper' }}> + <Typography>Are you sure you want to delete this project</Typography> + <Box display="flex" + flexDirection="row" + sx={{ + justifyContent: "center" + }} + pt={2} + width="100%" + > + <Button variant="contained" onClick={deleteProject}> + Yes I'm Sure + </Button> + </Box> + </Box> + </Fade> + </Box> + + </Box> + )} + </Grid> + <Grid item sm={4}> + <Box marginTop="2rem"> + <DeadlineGrid deadlines={deadlines} minWidth={0} /> + </Box> + </Grid> </Grid> - </Grid> + </Container> ); + } From 92242cd12155fa4e1612c8694aed1bf1e515e28e Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 23 May 2024 14:53:42 +0200 Subject: [PATCH 356/377] Fixed join code copy to clipboard (#404) * fixed join code copy * removed : --- frontend/src/components/Courses/CourseDetailTeacher.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index 6ba52b63..80142e00 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -461,7 +461,7 @@ function JoinCodeMenu({ const handleCopyToClipboard = (join_code: string) => { const host = window.location.host; navigator.clipboard.writeText( - `${host}/${i18next.resolvedLanguage}/courses/join?code=${join_code}` + `${window.location.protocol}//${host}/${i18next.resolvedLanguage}/courses/join?code=${join_code}` ); }; From 1fbd30c647cae7cae414663208a04679b92692ad Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Thu, 23 May 2024 14:55:45 +0200 Subject: [PATCH 357/377] Only return projects where u are student if visible for students is true (#403) * projects visible for students * lint --- backend/project/endpoints/projects/projects.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 27b603a3..94813bbd 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -43,13 +43,13 @@ def get(self, uid=None): } try: # Get all the courses a user is part of - courses = CourseStudent.query.filter_by(uid=uid).\ + courses_student = CourseStudent.query.filter_by(uid=uid).\ with_entities(CourseStudent.course_id).all() - courses += CourseAdmin.query.filter_by(uid=uid).\ + courses = CourseAdmin.query.filter_by(uid=uid).\ with_entities(CourseAdmin.course_id).all() courses += Course.query.filter_by(teacher=uid).with_entities(Course.course_id).all() courses = [c[0] for c in courses] # Remove the tuple wrapping the course_id - + courses_student = [c[0] for c in courses_student] # Filter the projects based on the query parameters filters = dict(request.args) conditions = [] @@ -62,6 +62,9 @@ def get(self, uid=None): projects = projects.filter(and_(*conditions)) if conditions else projects projects = projects.all() projects = [p for p in projects if get_course_of_project(p.project_id) in courses] + projects_student = Project.query.filter(Project.course_id.in_(courses_student)).all() + projects_student = [p for p in projects_student if p.visible_for_students] + projects += projects_student # Return the projects data["message"] = "Successfully fetched the projects" From 8509c1010e7c83b987c711cf366d7527c7d1a82a Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 14:56:17 +0200 Subject: [PATCH 358/377] added conditional rendering for edit funtionality (#402) --- .../pages/project/projectView/ProjectView.tsx | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 8b1f8dd8..f5f5df09 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -195,10 +195,9 @@ export default function ProjectView() { <Box position="relative" height="100%" sx={{marginTop: "10px"}}> <Stack direction="row" spacing={2}> { - !edit && <><Typography>{projectData.description}</Typography></> - } - { - edit && <><TextField id="edit-description" label="description" variant="outlined" size="small" defaultValue={description} onChange={(event) => setDescription(event.target.value)}/></> + !edit + ? <><Typography>{projectData.description}</Typography></> + : edit && <><TextField id="edit-description" label="description" variant="outlined" size="small" defaultValue={description} onChange={(event) => setDescription(event.target.value)}/></> } <Typography flex="1" /> </Stack> @@ -212,25 +211,23 @@ export default function ProjectView() { alignItems="flex-end" justifyContent="end" > - { - edit && ( - <> - <IconButton onClick={patchTitleAndDescription}> - <CheckIcon /> - </IconButton> - <IconButton onClick={discardEditTitle}> - <CloseIcon /> + { me && me.role === "TEACHER" && ( + edit + ? ( + <> + <IconButton onClick={patchTitleAndDescription}> + <CheckIcon /> + </IconButton> + <IconButton onClick={discardEditTitle}> + <CloseIcon /> + </IconButton> + </>) + : ( + <IconButton onClick={() => setEdit(true)}> + <EditIcon /> </IconButton> - </> - ) - } - { - !edit && ( - <IconButton onClick={() => setEdit(true)}> - <EditIcon /> - </IconButton> - ) - } + ) + )} </Box> </CardContent> </Card> @@ -242,7 +239,7 @@ export default function ProjectView() { projectId={projectId} /> </Box> - {me && me.role == "TEACHER" && ( + {me && me.role === "TEACHER" && ( <Box width="100%"> <Box display="flex" From 2d694e96057afb5f96a02cfefd5c07fce4a26c64 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 14:57:53 +0200 Subject: [PATCH 359/377] added translation for homepage of user guide (#400) * added translation * fix --- .../current/intro.md | 42 +------------------ 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md index 7c253496..02c86b75 100644 --- a/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md +++ b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/intro.md @@ -4,44 +4,4 @@ sidebar_position: 1 # Project user guide -If you need help using the you can read the user guide below. - -## Getting Started - -Get started by **creating a new site**. - -Or **try Docusaurus immediately** with **[docusaurus.new](https://docusaurus.new)**. - -### What you'll need - -- [Node.js](https://nodejs.org/en/download/) version 18.0 or above: - - When installing Node.js, you are recommended to check all checkboxes related to dependencies. - -## Generate a new site - -Generate a new Docusaurus site using the **classic template**. - -The classic template will automatically be added to your project after you run the command: - -```bash -npm init docusaurus@latest my-website classic -``` - -You can type this command into Command Prompt, Powershell, Terminal, or any other integrated terminal of your code editor. - -The command also installs all necessary dependencies you need to run Docusaurus. - -## Start your site - -Run the development server: - -```bash -cd my-website -npm run start -``` - -The `cd` command changes the directory you're working with. In order to work with your newly created Docusaurus site, you'll need to navigate the terminal there. - -The `npm run start` command builds your website locally and serves it through a development server, ready for you to view at http://localhost:3000/. - -Open `docs/intro.md` (this page) and edit some lines: the site **reloads automatically** and displays your changes. +Indien je hulp nodig hebt met het gebruik van projecct Péristeronas kan je de onderstaande gebruikershandleiding raadplegen. From d79bfd9b68d239945f704aad0e934a06f5907f1d Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Thu, 23 May 2024 14:59:57 +0200 Subject: [PATCH 360/377] Groups backend (#338) * temp bad * db_constr, model and first attempt at endpoint for group * group prim key (group_id,project_id) added delete endpoint to leave groups, next up is test * allow students in max 1 group * model tests * lint * group menu frontend * hm * working endpoint for create and delete group * translations * begone front * front removal * lintr * fixed changes, untested tho * groups locked var should not mess up all older code * only student or teacher can get groups ; unlock groups * linter mad * Very mad lintr * vscode linter errors should be more obvi * removed some teacher_id = None * removed unused import * bad prints --- backend/db_construct.sql | 20 ++- .../projects/groups/group_student.py | 128 ++++++++++++++++++ .../endpoints/projects/groups/groups.py | 127 +++++++++++++++++ .../endpoints/projects/project_endpoint.py | 7 +- backend/project/models/group.py | 17 +++ backend/project/models/group_student.py | 13 ++ backend/project/models/project.py | 1 + backend/project/utils/authentication.py | 22 +++ backend/tests/conftest.py | 9 +- backend/tests/models/group_test.py | 52 +++++++ 10 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 backend/project/endpoints/projects/groups/group_student.py create mode 100644 backend/project/endpoints/projects/groups/groups.py create mode 100644 backend/project/models/group.py create mode 100644 backend/project/models/group_student.py create mode 100644 backend/tests/models/group_test.py diff --git a/backend/db_construct.sql b/backend/db_construct.sql index b4614151..5b209431 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -20,8 +20,8 @@ CREATE TABLE courses ( ); CREATE TABLE course_join_codes ( - join_code UUID DEFAULT gen_random_uuid() NOT NULL, - course_id INT NOT NULL, + join_code UUID DEFAULT gen_random_uuid() NOT NULL, + course_id INT NOT NULL, expiry_time DATE, for_admins BOOLEAN NOT NULL, CONSTRAINT fk_course_join_link FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE, @@ -53,12 +53,28 @@ CREATE TABLE projects ( course_id INT NOT NULL, visible_for_students BOOLEAN NOT NULL, archived BOOLEAN NOT NULL, + groups_locked BOOLEAN DEFAULT FALSE, regex_expressions VARCHAR(50)[], runner runner, PRIMARY KEY(project_id), CONSTRAINT fk_course FOREIGN KEY(course_id) REFERENCES courses(course_id) ON DELETE CASCADE ); +CREATE TABLE groups ( + group_id INT GENERATED ALWAYS AS IDENTITY, + project_id INT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE, + group_size INT NOT NULL, + PRIMARY KEY(project_id, group_id) +); + +CREATE TABLE group_students ( + uid VARCHAR(255) NOT NULL REFERENCES users(uid) ON DELETE CASCADE, + group_id INT NOT NULL, + project_id INT NOT NULL, + PRIMARY KEY(uid, group_id, project_id), + CONSTRAINT fk_group_reference FOREIGN KEY (group_id, project_id) REFERENCES groups(group_id, project_id) ON DELETE CASCADE +); + CREATE TABLE submissions ( submission_id INT GENERATED ALWAYS AS IDENTITY, uid VARCHAR(255) NOT NULL, diff --git a/backend/project/endpoints/projects/groups/group_student.py b/backend/project/endpoints/projects/groups/group_student.py new file mode 100644 index 00000000..6a148a1f --- /dev/null +++ b/backend/project/endpoints/projects/groups/group_student.py @@ -0,0 +1,128 @@ +"""Endpoint for joining and leaving groups in a project""" + + +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv +from flask import request +from flask_restful import Resource +from sqlalchemy.exc import SQLAlchemyError + +from project.utils.query_agent import insert_into_model +from project.models.group import Group +from project.models.project import Project +from project.utils.authentication import authorize_student_submission + +from project import db + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(f"{API_URL}/", "groups") + + +class GroupStudent(Resource): + """Api endpoint to allow students to join and leave project groups""" + @authorize_student_submission + def post(self, project_id, group_id, uid=None): + """ + This function will allow students to join project groups if not full + """ + try: + project = db.session.query(Project).filter_by( + project_id=project_id).first() + if project.groups_locked: + return { + "message": "Groups are locked for this project", + "url": RESPONSE_URL + }, 400 + + group = db.session.query(Group).filter_by( + project_id=project_id, group_id=group_id).first() + if group is None: + return { + "message": "Group does not exist", + "url": RESPONSE_URL + }, 404 + + joined_groups = db.session.query(GroupStudent).filter_by( + uid=uid, project_id=project_id).all() + if len(joined_groups) > 0: + return { + "message": "Student is already in a group", + "url": RESPONSE_URL + }, 400 + + joined_students = db.session.query(GroupStudent).filter_by( + group_id=group_id, project_id=project_id).all() + if len(joined_students) >= group.group_size: + return { + "message": "Group is full", + "url": RESPONSE_URL + }, 400 + + req = request.json + req["project_id"] = project_id + req["group_id"] = group_id + req["uid"] = uid + return insert_into_model( + GroupStudent, + req, + RESPONSE_URL, + "group_id", + required_fields=["project_id", "group_id", "uid"] + ) + except SQLAlchemyError: + data = { + "url": urljoin(f"{API_URL}/", "projects") + } + data["message"] = "An error occurred while fetching the projects" + return data, 500 + + + @authorize_student_submission + def delete(self, project_id, group_id, uid=None): + """ + This function will allow students to leave project groups + """ + data = { + "url": urljoin(f"{API_URL}/", "projects") + } + try: + project = db.session.query(Project).filter_by( + project_id=project_id).first() + if project.groups_locked: + return { + "message": "Groups are locked for this project", + "url": RESPONSE_URL + }, 400 + + group = db.session.query(Group).filter_by( + project_id=project_id, group_id=group_id).first() + if group is None: + return { + "message": "Group does not exist", + "url": RESPONSE_URL + }, 404 + + if uid is None: + return { + "message": "Failed to verify uid of user", + "url": RESPONSE_URL + }, 400 + + student_group = db.session.query(GroupStudent).filter_by( + group_id=group_id, project_id=project_id, uid=uid).first() + if student_group is None: + return { + "message": "Student is not in the group", + "url": RESPONSE_URL + }, 404 + + db.session.delete(student_group) + db.session.commit() + data["message"] = "Student has succesfully left the group" + return data, 200 + + except SQLAlchemyError: + data["message"] = "An error occurred while fetching the projects" + return data, 500 diff --git a/backend/project/endpoints/projects/groups/groups.py b/backend/project/endpoints/projects/groups/groups.py new file mode 100644 index 00000000..a6b070e0 --- /dev/null +++ b/backend/project/endpoints/projects/groups/groups.py @@ -0,0 +1,127 @@ +"""Endpoint for creating/deleting groups in a project""" +from os import getenv +from urllib.parse import urljoin +from dotenv import load_dotenv +from flask import request +from flask_restful import Resource +from sqlalchemy.exc import SQLAlchemyError + +from project.models.project import Project +from project.models.group import Group +from project.utils.query_agent import query_selected_from_model, insert_into_model +from project.utils.authentication import ( + authorize_teacher_or_student_of_project, + authorize_teacher_of_project +) +from project import db + +load_dotenv() +API_URL = getenv("API_HOST") +RESPONSE_URL = urljoin(f"{API_URL}/", "groups") + + +class Groups(Resource): + """Api endpoint for the /project/project_id/groups link""" + + @authorize_teacher_of_project + def patch(self, project_id): + """ + This function will set locked state of project groups, + need to pass locked field in the body + """ + req = request.json + locked = req.get("locked") + if locked is None: + return { + "message": "Bad request: locked field is required", + "url": RESPONSE_URL + }, 400 + + try: + project = db.session.query(Project).filter_by( + project_id=project_id).first() + if project is None: + return { + "message": "Project does not exist", + "url": RESPONSE_URL + }, 404 + project.groups_locked = locked + db.session.commit() + + return { + "message": "Groups are locked", + "url": RESPONSE_URL + }, 200 + except SQLAlchemyError: + return { + "message": "Database error", + "url": RESPONSE_URL + }, 500 + + @authorize_teacher_or_student_of_project + def get(self, project_id): + """ + Get function for /project/project_id/groups this will be the main endpoint + to get all groups for a project + """ + return query_selected_from_model( + Group, + RESPONSE_URL, + url_mapper={"group_id": RESPONSE_URL}, + filters={"project_id": project_id} + ) + + @authorize_teacher_of_project + def post(self, project_id): + """ + This function will create a new group for a project + if the body of the post contains a group_size and project_id exists + """ + + req = request.json + req["project_id"] = project_id + return insert_into_model( + Group, + req, + RESPONSE_URL, + "group_id", + required_fields=["project_id", "group_size"] + ) + + @authorize_teacher_of_project + def delete(self, project_id): + """ + This function will delete a group + if group_id is provided and request is from teacher + """ + + req = request.json + group_id = req.get("group_id") + if group_id is None: + return { + "message": "Bad request: group_id is required", + "url": RESPONSE_URL + }, 400 + + try: + project = db.session.query(Project).filter_by( + project_id=project_id).first() + if project is None: + return { + "message": "Project associated with group does not exist", + "url": RESPONSE_URL + }, 404 + + group = db.session.query(Group).filter_by( + project_id=project_id, group_id=group_id).first() + db.session.delete(group) + db.session.commit() + return { + "message": "Group deleted", + "url": RESPONSE_URL + }, 204 + except SQLAlchemyError: + return { + "message": "Database error", + "url": RESPONSE_URL + }, 500 diff --git a/backend/project/endpoints/projects/project_endpoint.py b/backend/project/endpoints/projects/project_endpoint.py index 6fa7510a..2c9a09b9 100644 --- a/backend/project/endpoints/projects/project_endpoint.py +++ b/backend/project/endpoints/projects/project_endpoint.py @@ -10,7 +10,7 @@ from project.endpoints.projects.project_assignment_file import ProjectAssignmentFiles from project.endpoints.projects.project_submissions_download import SubmissionDownload from project.endpoints.projects.project_last_submission import SubmissionPerUser - +from project.endpoints.projects.groups.groups import Groups project_bp = Blueprint('project_endpoint', __name__) @@ -38,3 +38,8 @@ '/projects/<int:project_id>/latest-per-user', view_func=SubmissionPerUser.as_view('latest_per_user') ) + +project_bp.add_url_rule( + '/projects/<int:project_id>/groups', + view_func=Groups.as_view('groups') +) diff --git a/backend/project/models/group.py b/backend/project/models/group.py new file mode 100644 index 00000000..fca8060f --- /dev/null +++ b/backend/project/models/group.py @@ -0,0 +1,17 @@ +"""Group model""" +from dataclasses import dataclass +from sqlalchemy import Integer, Column, ForeignKey +from project import db + + +@dataclass +class Group(db.Model): + """ + This class will contain the model for the groups + """ + __tablename__ = "groups" + + group_id: int = Column(Integer, autoincrement=True, primary_key=True) + project_id: int = Column(Integer, ForeignKey( + "projects.project_id"), autoincrement=False, primary_key=True) + group_size: int = Column(Integer, nullable=False) diff --git a/backend/project/models/group_student.py b/backend/project/models/group_student.py new file mode 100644 index 00000000..57a337a2 --- /dev/null +++ b/backend/project/models/group_student.py @@ -0,0 +1,13 @@ +"""Model for relation between groups and students""" +from dataclasses import dataclass +from sqlalchemy import Integer, Column, ForeignKey, String +from project.db_in import db + +@dataclass +class GroupStudent(db.Model): + """Model for relation between groups and students""" + __tablename__ = "group_students" + + uid: str = Column(String(255), ForeignKey("users.uid"), primary_key=True) + group_id: int = Column(Integer, ForeignKey("groups.group_id"), primary_key=True) + project_id: int = Column(Integer, ForeignKey("groups.project_id"), primary_key=True) diff --git a/backend/project/models/project.py b/backend/project/models/project.py index 75e425e6..788864b0 100644 --- a/backend/project/models/project.py +++ b/backend/project/models/project.py @@ -55,6 +55,7 @@ class Project(db.Model): # pylint: disable=too-many-instance-attributes course_id: int = Column(Integer, ForeignKey("courses.course_id"), nullable=False) visible_for_students: bool = Column(Boolean, nullable=False) archived: bool = Column(Boolean, nullable=False) + groups_locked: bool = Column(Boolean) runner: Runner = Column( EnumField(Runner, name="runner"), nullable=False) diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index 30a79d68..75bbe08f 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -50,6 +50,7 @@ def wrap(*args, **kwargs): return f(*args, **kwargs) return wrap + def login_required_return_uid(f): """ This function will check if the person sending a request to the API is logged in @@ -62,6 +63,7 @@ def wrap(*args, **kwargs): return f(*args, **kwargs) return wrap + def authorize_admin(f): """ This function will check if the person sending a request to the API is logged in and an admin. @@ -169,6 +171,26 @@ def wrap(*args, **kwargs): return wrap +def authorize_teacher_or_student_of_project(f): + """ + This function will check if the person sending a request to the API is logged in, + and the teacher or student of the course which the project in the request belongs to. + Returns 403: Not Authorized if either condition is false + """ + @wraps(f) + def wrap(*args, **kwargs): + auth_user_id = return_authenticated_user_id() + project_id = kwargs["project_id"] + course_id = get_course_of_project(project_id) + + if (is_teacher_of_course(auth_user_id, course_id) or + is_student_of_course(auth_user_id, course_id)): + return f(*args, **kwargs) + + abort(make_response(({"message": """You are not authorized to perfom this action, + you are not the teacher OR student of this project"""}, 403))) + return wrap + def authorize_teacher_or_project_admin(f): """ This function will check if the person sending a request to the API is logged in, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8404d35f..8a3f8ff0 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,7 +17,7 @@ from project.models.project import Project from project.models.course_relation import CourseStudent,CourseAdmin from project.models.submission import Submission, SubmissionStatus - +from project.models.group import Group ### CLIENT & SESSION ### @@ -53,6 +53,8 @@ def session() -> Generator[Session, any, None]: session.commit() session.add_all(submissions(session)) session.commit() + session.add(group(session)) + session.commit() yield session finally: @@ -199,3 +201,8 @@ def submissions(session): submission_status= SubmissionStatus.SUCCESS ) ] + +def group(session): + """Return a group to populate the database""" + project_id = session.query(Project).filter_by(title="B+ Trees").first().project_id + return Group(project_id=project_id, group_size=4) diff --git a/backend/tests/models/group_test.py b/backend/tests/models/group_test.py new file mode 100644 index 00000000..1844fe9e --- /dev/null +++ b/backend/tests/models/group_test.py @@ -0,0 +1,52 @@ +"""Tests for the Group and GroupStudent model""" +from sqlalchemy.orm import Session + +from project.models.project import Project +from project.models.group import Group +from project.models.group_student import GroupStudent +from project.models.user import User + + +class TestGroupModel: + """Test class for Group and GroupStudent tests""" + + def test_group_model(self, session: Session): + "Group create test" + project = session.query(Project).first() + group = Group(project_id=project.project_id, group_size=4) + session.add(group) + session.commit() + assert session.query(Group).filter_by( + group_id=group.group_id, project_id=project.project_id) is not None + assert session.query(Group).first().group_size == 4 + + def test_group_join(self, session: Session): + """Group join test""" + project = session.query(Project).filter_by(title="B+ Trees").first() + group = session.query(Group).filter_by( + project_id=project.project_id).first() + student = session.query(User).first() + + student_group = GroupStudent( + group_id=group.group_id, uid=student.uid, project_id=project.project_id) + session.add(student_group) + session.commit() + assert session.query(GroupStudent).first().uid == student.uid + + def test_group_leave(self, session: Session): + """Group leave test""" + project = session.query(Project).filter_by(title="B+ Trees").first() + group = session.query(Group).filter_by( + project_id=project.project_id).first() + student = session.query(User).first() + + student_group = GroupStudent( + group_id=group.group_id, uid=student.uid, project_id=project.project_id) + session.add(student_group) + session.commit() + + session.delete(student_group) + session.commit() + + assert session.query(GroupStudent).filter_by( + uid=student.uid, group_id=group.group_id, project_id=project.project_id).first() is None From 253ab7b6458cc8e34a7bf498480f05b788639fc7 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 23 May 2024 15:13:02 +0200 Subject: [PATCH 361/377] unzipping submissions on submission (#406) * unzipping submissions * run_test -> run_tests * run_test -> run_tests --- .../endpoints/submissions/submissions.py | 11 +++++++++-- .../project/utils/submissions/evaluator.py | 19 +++++++++++-------- .../evaluators/general/entry_point.sh | 2 +- .../evaluators/python/entry_point.sh | 2 +- .../assignment/{run_test.sh => run_tests.sh} | 0 .../assignment/{run_test.sh => run_tests.sh} | 0 .../assignment/{run_test.sh => run_tests.sh} | 0 7 files changed, 22 insertions(+), 12 deletions(-) rename backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/{run_test.sh => run_tests.sh} (100%) rename backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/{run_test.sh => run_tests.sh} (100%) rename backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/{run_test.sh => run_tests.sh} (100%) diff --git a/backend/project/endpoints/submissions/submissions.py b/backend/project/endpoints/submissions/submissions.py index a47ca63b..b03490fe 100644 --- a/backend/project/endpoints/submissions/submissions.py +++ b/backend/project/endpoints/submissions/submissions.py @@ -7,6 +7,7 @@ from datetime import datetime from zoneinfo import ZoneInfo from shutil import rmtree +import zipfile from flask import request from flask_restful import Resource from sqlalchemy import exc, and_ @@ -104,7 +105,7 @@ def get(self, uid=None) -> dict[str, any]: return data, 500 @authorize_student_submission - def post(self, uid=None) -> dict[str, any]: + def post(self, uid=None) -> dict[str, any]: # pylint: disable=too-many-locals, too-many-branches, too-many-statements """Post a new submission to a project Returns: @@ -174,7 +175,13 @@ def post(self, uid=None) -> dict[str, any]: input_folder = path.join(submission.submission_path, "submission") makedirs(input_folder, exist_ok=True) for file in files: - file.save(path.join(input_folder, file.filename)) + file_path = path.join(input_folder, file.filename) + file.save(file_path) + if file.filename.endswith(".zip"): + with zipfile.ZipFile(file_path) as upload_zip: + upload_zip.extractall(input_folder) + + except OSError: rmtree(submission.submission_path) session.rollback() diff --git a/backend/project/utils/submissions/evaluator.py b/backend/project/utils/submissions/evaluator.py index 4a772cdd..50e75dc0 100644 --- a/backend/project/utils/submissions/evaluator.py +++ b/backend/project/utils/submissions/evaluator.py @@ -82,15 +82,18 @@ def run_evaluator(submission: Submission, project_path: str, evaluator: str, is_ Returns: int: The exit code of the evaluator. """ - status_code = evaluate(submission, project_path, evaluator, is_late) - - if not is_late: - if status_code == 0: - submission.submission_status = 'SUCCESS' + try: + status_code = evaluate(submission, project_path, evaluator, is_late) + if not is_late: + if status_code == 0: + submission.submission_status = 'SUCCESS' + else: + submission.submission_status = 'FAIL' else: - submission.submission_status = 'FAIL' - else: - submission.submission_status = 'LATE' + submission.submission_status = 'LATE' + except: # pylint: disable=bare-except + submission.submission_status = 'FAIL' + try: db.session.merge(submission) diff --git a/backend/project/utils/submissions/evaluators/general/entry_point.sh b/backend/project/utils/submissions/evaluators/general/entry_point.sh index 9cdc7a66..51758446 100644 --- a/backend/project/utils/submissions/evaluators/general/entry_point.sh +++ b/backend/project/utils/submissions/evaluators/general/entry_point.sh @@ -1,3 +1,3 @@ #!/bin/bash -bash /tests/run_test.sh +bash /tests/run_tests.sh diff --git a/backend/project/utils/submissions/evaluators/python/entry_point.sh b/backend/project/utils/submissions/evaluators/python/entry_point.sh index e24a883a..4518c789 100644 --- a/backend/project/utils/submissions/evaluators/python/entry_point.sh +++ b/backend/project/utils/submissions/evaluators/python/entry_point.sh @@ -37,4 +37,4 @@ fi echo "Running tests..." ls /submission -bash /tests/run_test.sh \ No newline at end of file +bash /tests/run_tests.sh \ No newline at end of file diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.sh b/backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_tests.sh similarity index 100% rename from backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_test.sh rename to backend/tests/utils/submission_evaluators/resources/python/tc_1/assignment/run_tests.sh diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_test.sh b/backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_tests.sh similarity index 100% rename from backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_test.sh rename to backend/tests/utils/submission_evaluators/resources/python/tc_2/assignment/run_tests.sh diff --git a/backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_test.sh b/backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_tests.sh similarity index 100% rename from backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_test.sh rename to backend/tests/utils/submission_evaluators/resources/python/tc_3/assignment/run_tests.sh From df9587f426b6b07deed23c6542015357f1a55546 Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 23 May 2024 15:35:11 +0200 Subject: [PATCH 362/377] Fixed deadline time left (#407) * fixed deadline time left * > to >= --- frontend/src/components/Courses/CourseUtilComponents.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Courses/CourseUtilComponents.tsx b/frontend/src/components/Courses/CourseUtilComponents.tsx index 2c33db07..70889c63 100644 --- a/frontend/src/components/Courses/CourseUtilComponents.tsx +++ b/frontend/src/components/Courses/CourseUtilComponents.tsx @@ -286,10 +286,10 @@ function EmptyOrNotProjects({ const deadlineDate = deadline.date; const diffTime = Math.abs(deadlineDate.getTime() - now.getTime()); const diffHours = Math.ceil(diffTime / (1000 * 60 * 60)); - const diffDays = Math.ceil(diffHours * 24); + const diffDays = Math.floor(diffHours / 24); timeLeft = - diffDays > 1 ? `${diffDays} days` : `${diffHours} hours`; + diffDays >= 1 ? `${diffDays} days` : `${diffHours} hours`; } } return ( From e7129cd44bc091ef137bf02fb1d20ef1b14d58eb Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 23 May 2024 16:27:37 +0200 Subject: [PATCH 363/377] Fix #408 (#409) Co-authored-by: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> --- frontend/src/utils/fetches/FetchProjects.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/utils/fetches/FetchProjects.tsx b/frontend/src/utils/fetches/FetchProjects.tsx index 64bbbad0..969bf154 100644 --- a/frontend/src/utils/fetches/FetchProjects.tsx +++ b/frontend/src/utils/fetches/FetchProjects.tsx @@ -5,15 +5,16 @@ import { ProjectDeadline, ShortSubmission, } from "../../pages/project/projectDeadline/ProjectDeadline.tsx"; +import { Me } from "../../types/me.ts"; const API_URL = import.meta.env.VITE_APP_API_HOST; export const fetchProjectPage = async () => { - const projects = await fetchProjects(); const me = await fetchMe(); + const projects = await fetchProjects(me); return { projects, me }; }; -export const fetchProjects = async () => { +export const fetchProjects = async (me: Me) => { try { const response = await authenticatedFetch(`${API_URL}/projects`); const jsonData = await response.json(); @@ -24,7 +25,7 @@ export const fetchProjects = async () => { const project_id = url_split[url_split.length - 1]; const response_submissions = await ( await authenticatedFetch( - encodeURI(`${API_URL}/submissions?project_id=${project_id}`) + encodeURI(`${API_URL}/submissions?project_id=${project_id}&uid=${me.uid}`) ) ).json(); From 50dfaaf9482971d508cc0b2b1cfc29e2890ceaba Mon Sep 17 00:00:00 2001 From: Cedric Mekeirle <143823820+JibrilExe@users.noreply.github.com> Date: Thu, 23 May 2024 16:36:23 +0200 Subject: [PATCH 364/377] Filter on conditions the students projects (#411) * projects visible for students * lint * filter on conditions * lint * fixed bad merge --- backend/project/endpoints/projects/projects.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 94813bbd..03d46112 100644 --- a/backend/project/endpoints/projects/projects.py +++ b/backend/project/endpoints/projects/projects.py @@ -62,7 +62,10 @@ def get(self, uid=None): projects = projects.filter(and_(*conditions)) if conditions else projects projects = projects.all() projects = [p for p in projects if get_course_of_project(p.project_id) in courses] - projects_student = Project.query.filter(Project.course_id.in_(courses_student)).all() + projects_student = Project.query.filter(Project.course_id.in_(courses_student)) + projects_student = projects_student.filter(and_(*conditions)) \ + if conditions else projects_student + projects_student = projects_student.all() projects_student = [p for p in projects_student if p.visible_for_students] projects += projects_student From c979408855789202e5b868b5ce4fdc746be7473b Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Thu, 23 May 2024 16:46:18 +0200 Subject: [PATCH 365/377] show red dots on home page (#412) --- frontend/src/pages/home/HomePage.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index 35775325..94956541 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -11,14 +11,13 @@ import { import { DateCalendar } from "@mui/x-date-pickers/DateCalendar"; import { DayCalendarSkeleton, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import dayjs, { Dayjs } from "dayjs"; import { PickersDay, PickersDayProps } from "@mui/x-date-pickers/PickersDay"; import { ProjectDeadlineCard } from "../project/projectDeadline/ProjectDeadlineCard.tsx"; import { ProjectDeadline } from "../project/projectDeadline/ProjectDeadline.tsx"; import { useLoaderData } from "react-router-dom"; import { Me } from "../../types/me.ts"; - interface DeadlineInfoProps { selectedDay: Dayjs; deadlines: ProjectDeadline[]; @@ -144,6 +143,11 @@ export default function HomePage() { .sort((a, b) => dayjs(b.deadline).diff(dayjs(a.deadline))) .slice(0, 3); // only show the first 3 const noDeadlineProject = projects.filter((p) => p.deadline === undefined); + + useEffect(() => { + handleMonthChange(selectedDay, projects, setHighlightedDays); + }, [projects, selectedDay]); + return ( <Container style={{ paddingTop: "50px" }}> <Grid container spacing={2} wrap="nowrap"> From 15f0c705b3d5920c49b44a7e361d9180f11d58d1 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 23 May 2024 16:47:24 +0200 Subject: [PATCH 366/377] Project issues (#410) * Fixing the legacy warning * Adding auth decorators to the project submission download enpoints * Fixing query parameter tests * remove bad param filter --- .../project/endpoints/projects/project_last_submission.py | 2 ++ .../endpoints/projects/project_submissions_download.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/project/endpoints/projects/project_last_submission.py b/backend/project/endpoints/projects/project_last_submission.py index 5b998c25..6cd4e719 100644 --- a/backend/project/endpoints/projects/project_last_submission.py +++ b/backend/project/endpoints/projects/project_last_submission.py @@ -6,6 +6,7 @@ from urllib.parse import urljoin from flask_restful import Resource from project.endpoints.projects.project_submissions_download import get_last_submissions_per_user +from project.utils.authentication import authorize_teacher_or_project_admin API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") @@ -16,6 +17,7 @@ class SubmissionPerUser(Resource): Recourse to get all the submissions for users """ + @authorize_teacher_or_project_admin def get(self, project_id: int): """ Download all submissions for a project as a zip file. diff --git a/backend/project/endpoints/projects/project_submissions_download.py b/backend/project/endpoints/projects/project_submissions_download.py index 6ba93e93..31d8e80a 100644 --- a/backend/project/endpoints/projects/project_submissions_download.py +++ b/backend/project/endpoints/projects/project_submissions_download.py @@ -14,6 +14,7 @@ from project.models.project import Project from project.models.submission import Submission from project.db_in import db +from project.utils.authentication import authorize_teacher_or_project_admin API_HOST = getenv("API_HOST") UPLOAD_FOLDER = getenv("UPLOAD_FOLDER") @@ -24,7 +25,7 @@ def get_last_submissions_per_user(project_id): Get the last submissions per user for a given project """ try: - project = Project.query.get(project_id) + project = db.session.get(Project, project_id) except SQLAlchemyError: return {"message": "Internal server error"}, 500 @@ -57,6 +58,8 @@ class SubmissionDownload(Resource): """ Resource to download all submissions for a project. """ + + @authorize_teacher_or_project_admin def get(self, project_id: int): """ Download all submissions for a project as a zip file. From 3d4fe01a88ba91210df9c620d0408f89fe0b4721 Mon Sep 17 00:00:00 2001 From: Jarne Clauw <67628242+JarneClauw@users.noreply.github.com> Date: Thu, 23 May 2024 17:50:27 +0200 Subject: [PATCH 367/377] Project tests (#416) * Auth tests * Fixing the legacy warning * Adding auth decorators to the project submission download enpoints * Fixing auth tests * Broken data field and query parameter tests * Fixing query parameter tests * More tests but first fixing some issues * working tests * remove bad param filter * stuff --- backend/tests/endpoints/conftest.py | 73 ++++++++-- backend/tests/endpoints/project_test.py | 168 +++++++++++++++++++++++- 2 files changed, 228 insertions(+), 13 deletions(-) diff --git a/backend/tests/endpoints/conftest.py b/backend/tests/endpoints/conftest.py index 3f7717d7..64e44f0d 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -4,6 +4,9 @@ from datetime import datetime from zoneinfo import ZoneInfo from typing import Any +from zipfile import ZipFile +import os + import pytest from pytest import fixture, FixtureRequest from flask.testing import FlaskClient @@ -125,12 +128,13 @@ def course(session: Session, student: User, teacher: User, admin: User) -> Cours return course + ### PROJECTS ### @fixture def project(session: Session, course: Course): """Return a project entry""" project = Project( - title="Test project", + title="project", description="Test project", deadlines=[{"deadline":"2024-05-23T21:59:59", "description":"Final deadline"}], course_id=course.course_id, @@ -143,6 +147,45 @@ def project(session: Session, course: Course): session.commit() return project +@fixture +def project_invisible(session: Session, course: Course): + """Return a project entry that is not visible for the student""" + project = Project( + title="invisible project", + description="Test project", + deadlines=[{"deadline":"2024-05-23T21:59:59", "description":"Final deadline"}], + course_id=course.course_id, + visible_for_students=False, + archived=False, + runner=Runner.GENERAL, + regex_expressions=[".*.pdf"] + ) + session.add(project) + session.commit() + return project + +@fixture +def project_archived(session: Session, course: Course): + """Return a project entry that is not visible for the student""" + project = Project( + title="archived project", + description="Test project", + deadlines=[{"deadline":"2024-05-23T21:59:59", "description":"Final deadline"}], + course_id=course.course_id, + visible_for_students=True, + archived=True, + runner=Runner.GENERAL, + regex_expressions=[".*.pdf"] + ) + session.add(project) + session.commit() + return project + +@fixture +def projects(project: Project, project_invisible: Project, project_archived: Project): + """Return a list of project entries""" + return [project, project_invisible, project_archived] + ### SUBMISSIONS ### @@ -161,13 +204,6 @@ def submission(session: Session, student: User, project: Project): return submission ### FILES ### -@fixture -def file_empty(): - """Return an empty file""" - descriptor, name = tempfile.mkstemp() - with open(descriptor, "rb") as temp: - yield temp, name - @fixture def file_no_name(): """Return a file with no name""" @@ -176,15 +212,34 @@ def file_no_name(): temp.write("This is a test file.") with open(name, "rb") as temp: yield temp, "" + os.remove(name) @fixture def files(): """Return a temporary file""" - name = "/tmp/test.pdf" + name = "test.pdf" with open(name, "w", encoding="UTF-8") as file: file.write("This is a test file.") with open(name, "rb") as file: yield [(file, name)] + os.remove(name) + +@fixture +def file_assignment(): + """Return an assignment file for a project""" + assignment_file = "assignment.md" + assignment_content = "# Assignment" + with open(assignment_file, "w", encoding="UTF-8") as file: + file.write(assignment_content) + + zip_file = "project.zip" + with ZipFile(zip_file, "w") as zipf: + zipf.write(assignment_file) + + yield (zipf, zip_file) + + os.remove(assignment_file) + os.remove(zip_file) diff --git a/backend/tests/endpoints/project_test.py b/backend/tests/endpoints/project_test.py index 0884145a..2c4f56ab 100644 --- a/backend/tests/endpoints/project_test.py +++ b/backend/tests/endpoints/project_test.py @@ -1,9 +1,173 @@ """Tests for project endpoints.""" +from typing import Any import json +from pytest import mark +from flask.testing import FlaskClient + +from project.models.project import Project from tests.utils.auth_login import get_csrf_from_login +from tests.endpoints.endpoint import ( + TestEndpoint, + authentication_tests, + authorization_tests, + query_parameter_tests +) + +class TestProjectsEndpoint(TestEndpoint): + """Class to test the projects API endpoint""" + + ### AUTHENTICATION ### + # Where is login required + authentication_tests = \ + authentication_tests("/projects", ["get", "post"]) + \ + authentication_tests("/projects/@project_id", ["get", "patch", "delete"]) + \ + authentication_tests("/projects/@project_id/assignment", ["get"]) + \ + authentication_tests("/projects/@project_id/submissions-download", ["get"]) + \ + authentication_tests("/projects/@project_id/latest-per-user", ["get"]) + + @mark.parametrize("auth_test", authentication_tests, indirect=True) + def test_authentication(self, auth_test: tuple[str, Any, str, bool]): + """Test the authentication""" + super().authentication(auth_test) + + + + ### AUTHORIZATION ### + # Who can access what + authorization_tests = \ + authorization_tests("/projects", "get", + ["student", "student_other", "teacher", "teacher_other", "admin", "admin_other"], + []) + \ + authorization_tests("/projects", "post", + ["teacher"], + ["student", "student_other", "teacher_other", "admin", "admin_other"]) + \ + authorization_tests("/projects/@project_id", "get", + ["student", "teacher", "admin"], + ["student_other", "teacher_other", "admin_other"]) + \ + authorization_tests("/projects/@project_id", "patch", + ["teacher", "admin"], + ["student", "student_other", "teacher_other", "admin_other"]) + \ + authorization_tests("/projects/@project_id", "delete", + ["teacher"], + ["student", "student_other", "teacher_other", "admin", "admin_other"]) + \ + authorization_tests("/projects/@project_id/assignment", "get", + ["student", "teacher", "admin"], + ["student_other", "teacher_other", "admin_other"]) + \ + authorization_tests("/projects/@project_id/submissions-download", "get", + ["teacher", "admin"], + ["student", "student_other", "teacher_other", "admin_other"]) + \ + authorization_tests("/projects/@project_id/latest-per-user", "get", + ["teacher", "admin"], + ["student_other", "teacher_other", "admin_other"]) + + @mark.parametrize("auth_test", authorization_tests, indirect=True) + def test_authorization(self, auth_test: tuple[str, Any, str, bool]): + """Test the authorization""" + super().authorization(auth_test) + + + + ### QUERY PARAMETER ### + # Test a query parameter, should return [] for wrong values + query_parameter_tests = \ + query_parameter_tests("/projects", "get", "student", ["project_id", "title", "course_id"]) + + @mark.parametrize("query_parameter_test", query_parameter_tests, indirect=True) + def test_query_parameters(self, query_parameter_test: tuple[str, Any, str, bool]): + """Test a query parameter""" + super().query_parameter(query_parameter_test) + + + + ### PROJECTS ### + def test_get_projects(self, client: FlaskClient, projects: list[Project]): + """Test getting all projects""" + response = client.get( + "/projects", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "student")} + ) + assert response.status_code == 200 + data = response.json["data"] + assert [project["title"] in ["project", "archived project"] for project in data] + + def test_get_projects_project_id( + self, client: FlaskClient, api_host: str, project: Project, projects: list[Project] + ): + """Test getting all projects for a given project_id""" + response = client.get( + f"/projects?project_id={project.project_id}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "teacher")} + ) + assert response.status_code == 200 + data = response.json["data"] + assert len(data) == 1 + assert data[0]["project_id"] == f"{api_host}/projects/{project.project_id}" + + def test_get_projects_title( + self, client: FlaskClient, project: Project, projects: list[Project] + ): + """Test getting all projects for a given title""" + response = client.get( + f"/projects?title={project.title}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "teacher")} + ) + assert response.status_code == 200 + data = response.json["data"] + assert len(data) == 1 + assert data[0]["title"] == project.title + def test_get_projects_course_id( + self, client: FlaskClient, project: Project, projects: list[Project] + ): + """Test getting all projects for a given course_id""" + response = client.get( + f"/projects?course_id={project.course_id}", + headers = {"X-CSRF-TOKEN":get_csrf_from_login(client, "teacher")} + ) + assert response.status_code == 200 + assert len(response.json["data"]) == len(projects) + + + + ### PROJECT ### + def test_patch_project(self, client: FlaskClient, project: Project): + """Test patching a project""" + csrf = get_csrf_from_login(client, "teacher") + response = client.patch( + f"/projects/{project.project_id}", + headers = {"X-CSRF-TOKEN":csrf}, + data = { + "title": "A new title" + } + ) + assert response.status_code == 200 + response = client.get( + f"/projects/{project.project_id}", + headers = {"X-CSRF-TOKEN":csrf} + ) + assert response.status_code == 200 + data = response.json["data"] + assert data["title"] == "A new title" + + def test_delete_project(self, client: FlaskClient, project: Project): + """Test deleting a project""" + csrf = get_csrf_from_login(client, "teacher") + response = client.delete( + f"/projects/{project.project_id}", + headers = {"X-CSRF-TOKEN":csrf} + ) + assert response.status_code == 200 + response = client.get( + f"/projects/{project.project_id}", + headers = {"X-CSRF-TOKEN":csrf} + ) + assert response.status_code == 404 + + + +### OLD TESTS ### def test_assignment_download(client, valid_project): """ Method for assignment download @@ -26,7 +190,6 @@ def test_assignment_download(client, valid_project): # 404 because the file is not found, no assignment.md in zip file assert response.status_code == 404 - def test_not_found_download(client): """ Test a not present project download @@ -37,14 +200,12 @@ def test_not_found_download(client): response = client.get("/projects/-1/assignments", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 404 - def test_projects_home(client): """Test home project endpoint.""" csrf = get_csrf_from_login(client, "teacher1") response = client.get("/projects", headers = {"X-CSRF-TOKEN":csrf}) assert response.status_code == 200 - def test_getting_all_projects(client): """Test getting all projects""" csrf = get_csrf_from_login(client, "teacher1") @@ -52,7 +213,6 @@ def test_getting_all_projects(client): assert response.status_code == 200 assert isinstance(response.json['data'], list) - def test_post_project(client, valid_project): """Test posting a project to the database and testing if it's present""" valid_project["deadlines"] = json.dumps(valid_project["deadlines"]) From dd84501ba13ca3d2d3d39c3b97ef24c469c75499 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 17:57:08 +0200 Subject: [PATCH 368/377] added 403 for overview page (#414) * added 403 for overview page * linter * added good response.ok placement and status code for 404 * linter --- frontend/src/loaders/submission-overview-loader.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/loaders/submission-overview-loader.ts b/frontend/src/loaders/submission-overview-loader.ts index 2ee5ab7c..26008260 100644 --- a/frontend/src/loaders/submission-overview-loader.ts +++ b/frontend/src/loaders/submission-overview-loader.ts @@ -27,10 +27,20 @@ export default async function loadSubmissionOverview({ }: { params: Params<string>; }) { + const projectId = params.projectId; const projectResponse = await authenticatedFetch( `${APIURL}/projects/${projectId}` ); + + if (!projectResponse.ok) { + if (projectResponse.status == 403) { + throw new Response("Not authenticated", {status: 403}); + } else if (projectResponse.status == 404) { + throw new Response("Not found", {status: 404}); + } + } + const projectData = (await projectResponse.json())["data"]; const overviewResponse = await authenticatedFetch( From ff7dd73de9d424245e1a133a6e8b02c163c542bd Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Thu, 23 May 2024 19:11:41 +0200 Subject: [PATCH 369/377] Student course overview (#417) * student overview * linter * linter * linter * check if teacher --- frontend/src/App.tsx | 4 +- .../Courses/CourseDetailStudent.tsx | 94 +++++++++++++++++++ .../Courses/CourseDetailTeacher.tsx | 5 +- .../src/components/Courses/CourseUtils.tsx | 3 +- .../src/components/Courses/CoursesDetail.tsx | 24 +++++ 5 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/Courses/CourseDetailStudent.tsx create mode 100644 frontend/src/components/Courses/CoursesDetail.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3a967a0..53c4d5ec 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,6 @@ import { } from "react-router-dom"; import Layout from "./components/Header/Layout"; import { AllCoursesTeacher } from "./components/Courses/AllCoursesTeacher"; -import { CourseDetailTeacher } from "./components/Courses/CourseDetailTeacher"; import { dataLoaderCourseDetail, dataLoaderCourses, @@ -23,6 +22,7 @@ import { synchronizeJoinCode } from "./loaders/join-code.ts"; import { fetchMe } from "./utils/fetches/FetchMe.ts"; import {fetchProjectForm} from "./components/ProjectForm/project-form.ts"; import loadSubmissionOverview from "./loaders/submission-overview-loader.ts"; +import CoursesDetail from "./components/Courses/CoursesDetail.tsx"; const router = createBrowserRouter( createRoutesFromElements( @@ -38,7 +38,7 @@ const router = createBrowserRouter( <Route path="courses"> <Route index element={<AllCoursesTeacher />} loader={dataLoaderCourses}/> <Route path="join" loader={synchronizeJoinCode} /> - <Route path=":courseId" element={<CourseDetailTeacher />} loader={dataLoaderCourseDetail} /> + <Route path=":courseId" element={<CoursesDetail />} loader={dataLoaderCourseDetail} /> </Route> <Route path="projects"> <Route diff --git a/frontend/src/components/Courses/CourseDetailStudent.tsx b/frontend/src/components/Courses/CourseDetailStudent.tsx new file mode 100644 index 00000000..9a211db2 --- /dev/null +++ b/frontend/src/components/Courses/CourseDetailStudent.tsx @@ -0,0 +1,94 @@ +import { + Grid, + Paper, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { + Course, ProjectDetail, +} from "./CourseUtils"; +import { + useLoaderData, +} from "react-router-dom"; +import { Title } from "../Header/Title"; +import { Me } from "../../types/me"; +import {EmptyOrNotProjects} from "./CourseDetailTeacher" + +/** + * + * @returns A jsx component representing the course detail page for a teacher + */ +export default function CourseDetailStudent() { + + const courseDetail = useLoaderData() as { + course: Course; + projects: ProjectDetail[]; + adminMes: Me[]; + studentMes: Me[]; + me:Me; + }; + const { course, projects, adminMes } = courseDetail; + const { t } = useTranslation("translation", { + keyPrefix: "courseDetailTeacher", + }); + + return ( + <> + <Title title={course.name}> + + + +
+ {t("projects")}: + +
+
+
+ + + + + {t("admins")}: + + {adminMes.map((admin: Me) => ( + + + + {admin.display_name} + + + + + ))} + + + + + +
+ + ); +} diff --git a/frontend/src/components/Courses/CourseDetailTeacher.tsx b/frontend/src/components/Courses/CourseDetailTeacher.tsx index 80142e00..4b512ccc 100644 --- a/frontend/src/components/Courses/CourseDetailTeacher.tsx +++ b/frontend/src/components/Courses/CourseDetailTeacher.tsx @@ -113,7 +113,7 @@ function handleDeleteCourse( * * @returns A jsx component representing the course detail page for a teacher */ -export function CourseDetailTeacher(): JSX.Element { +export default function CourseDetailTeacher() { const [selectedStudents, setSelectedStudents] = useState([]); const [anchorEl, setAnchorElStudent] = useState(null); const openCodes = Boolean(anchorEl); @@ -129,6 +129,7 @@ export function CourseDetailTeacher(): JSX.Element { projects: ProjectDetail[]; adminMes: Me[]; studentMes: Me[]; + me:Me; }; const { course, projects, adminMes, studentMes } = courseDetail; const { t } = useTranslation("translation", { @@ -276,7 +277,7 @@ export function CourseDetailTeacher(): JSX.Element { * @param projects - The array of projects. * @returns Either a place holder for no projects or a grid of cards describing the projects. */ -function EmptyOrNotProjects({ +export function EmptyOrNotProjects({ projects, }: { projects: ProjectDetail[]; diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 118f8b88..8525f930 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -247,5 +247,6 @@ export const dataLoaderCourseDetail = async ({ const student_uids = students.map((student: {uid: string}) => getIdFromLink(student.uid)); const adminMes = await fetchMes([course.teacher, ...admin_uids]); const studentMes = await fetchMes(student_uids); - return { course, projects, adminMes, studentMes }; + const me = await fetchMe(); + return { course, projects, adminMes, studentMes, me}; }; diff --git a/frontend/src/components/Courses/CoursesDetail.tsx b/frontend/src/components/Courses/CoursesDetail.tsx new file mode 100644 index 00000000..a697f58b --- /dev/null +++ b/frontend/src/components/Courses/CoursesDetail.tsx @@ -0,0 +1,24 @@ +import {useLoaderData} from "react-router-dom"; +import {Me} from "../../types/me.ts"; +import {Course, ProjectDetail} from "./CourseUtils.tsx"; +import CourseDetailTeacher from "./CourseDetailTeacher.tsx"; +import CourseDetailStudent from "./CourseDetailStudent.tsx"; + +/** + * gives the right detail page + * @returns - detail page + */ +export default function CoursesDetail() :JSX.Element { + const loader = useLoaderData() as { + course: Course; + projects: ProjectDetail[]; + adminMes: Me[]; + studentMes: Me[]; + me:Me; + }; + if (loader.course.teacher === loader.me.uid) { + return ; + } else { + return ; + } +} \ No newline at end of file From aa7383f975db1f90a3d3582566e0a3654db1a105 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 23 May 2024 20:07:08 +0200 Subject: [PATCH 370/377] fixed authorizations in projectview (#418) * fixed authorizations in projectview * linting * added locales --- frontend/public/locales/en/translation.json | 3 + frontend/public/locales/nl/translation.json | 3 + frontend/src/App.tsx | 31 +- frontend/src/loaders/project-view-loader.ts | 84 +++++ .../pages/project/projectView/ProjectView.tsx | 311 +++++++++--------- 5 files changed, 263 insertions(+), 169 deletions(-) create mode 100644 frontend/src/loaders/project-view-loader.ts diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index e6b5c6e5..317f62c8 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -65,6 +65,9 @@ "submit": "Submit", "previousSubmissions": "Previous Submissions", "noFileSelected": "No file selected", + "delete": "Delete", + "deleteProjectWarning": "Are you sure you want to delete this project?", + "imSure": "Yes, I'm sure", "submissionGrid": { "late": "Late", "fail": "Fail", diff --git a/frontend/public/locales/nl/translation.json b/frontend/public/locales/nl/translation.json index 7715aecd..c74cb996 100644 --- a/frontend/public/locales/nl/translation.json +++ b/frontend/public/locales/nl/translation.json @@ -102,6 +102,9 @@ "submit": "Indienen", "previousSubmissions": "Vorige indieningen", "noFileSelected": "Er is geen bestand geselecteerd", + "delete": "Verwijder", + "deleteProjectWarning": "Bent u zeker dat u dit project wilt verwijderen?", + "imSure": "Ja, ik ben zeker", "submissionGrid": { "late": "Te laat", "fail": "Gefaald", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 53c4d5ec..6d2cad70 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,9 +20,10 @@ import HomePages from "./pages/home/HomePages.tsx"; import ProjectOverView from "./pages/project/projectOverview.tsx"; import { synchronizeJoinCode } from "./loaders/join-code.ts"; import { fetchMe } from "./utils/fetches/FetchMe.ts"; -import {fetchProjectForm} from "./components/ProjectForm/project-form.ts"; +import { fetchProjectForm } from "./components/ProjectForm/project-form.ts"; import loadSubmissionOverview from "./loaders/submission-overview-loader.ts"; import CoursesDetail from "./components/Courses/CoursesDetail.tsx"; +import loadProjectViewData from "./loaders/project-view-loader.ts"; const router = createBrowserRouter( createRoutesFromElements( @@ -36,9 +37,17 @@ const router = createBrowserRouter( }> } loader={fetchProjectPage} /> - } loader={dataLoaderCourses}/> + } + loader={dataLoaderCourses} + /> - } loader={dataLoaderCourseDetail} /> + } + loader={dataLoaderCourseDetail} + /> } /> - }> - } loader={fetchProjectForm}/> + } + loader={loadProjectViewData} + > + } + loader={fetchProjectForm} + /> - , - ), + + ) ); /** diff --git a/frontend/src/loaders/project-view-loader.ts b/frontend/src/loaders/project-view-loader.ts new file mode 100644 index 00000000..0a9cf6f8 --- /dev/null +++ b/frontend/src/loaders/project-view-loader.ts @@ -0,0 +1,84 @@ +import { Params } from "react-router-dom"; +import { Deadline } from "../types/deadline"; +import { authenticatedFetch } from "../utils/authenticated-fetch"; +import { fetchMe } from "../utils/fetches/FetchMe"; +import i18next from "i18next"; + +const API_URL = import.meta.env.VITE_APP_API_HOST; + +/** + * + * @param param0 - params: Params + * @returns - projectData: projectData, + * courseData: courseData, + * me: me, + * assignmentText: assignmentText, + * isAdmin: isAdmin + */ +export default async function loadProjectViewData({ + params, +}: { + params: Params; +}) { + const me = await fetchMe(); + + const projectId = params.projectId; + + const assignmentResponse = await authenticatedFetch( + `${API_URL}/projects/${projectId}/assignment?lang=${i18next.resolvedLanguage}` + ); + + let assignmentText; + + if (assignmentResponse.ok) { + assignmentText = await assignmentResponse.text(); + } else { + throw new Response(assignmentResponse.statusText, { + status: assignmentResponse.status, + }); + } + + const response = await authenticatedFetch(`${API_URL}/projects/${projectId}`); + if (response.ok) { + const data = await response.json(); + const projectData = data["data"]; + + const transformedDeadlines = projectData.deadlines.map( + (deadlineArray: string[]): Deadline => ({ + description: deadlineArray[0], + deadline: deadlineArray[1], + }) + ); + + projectData["deadlines"] = transformedDeadlines; + + const courseResponse = await authenticatedFetch( + `${API_URL}/courses/${projectData.course_id}` + ); + + let courseData; + if (courseResponse.ok) { + courseData = (await courseResponse.json())["data"]; + } else { + throw new Response(response.statusText, { status: response.status }); + } + + courseData["admins"] = courseData["admins"].map((admin: string) => { + const urlSplit = admin.split("/"); + return urlSplit[urlSplit.length - 1]; + }); + + const isAdmin = + me.uid === courseData["teacher"] || courseData["admins"].includes(me.uid); + + return { + projectData: projectData, + courseData: courseData, + me: me, + assignmentText: assignmentText, + isAdmin: isAdmin, + }; + } else { + throw new Response(response.statusText, { status: response.status }); + } +} diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index f5f5df09..8d5a2484 100644 --- a/frontend/src/pages/project/projectView/ProjectView.tsx +++ b/frontend/src/pages/project/projectView/ProjectView.tsx @@ -6,27 +6,32 @@ import { CardHeader, Container, Fade, - Grid, IconButton, + Grid, + IconButton, Stack, TextField, Typography, } from "@mui/material"; -import {useCallback, useEffect, useState} from "react"; +import { useState } from "react"; import Markdown from "react-markdown"; -import {useLocation, useNavigate, useParams} from "react-router-dom"; +import { + useLoaderData, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; import SubmissionCard from "./SubmissionCard"; import { Course } from "../../../types/course"; import { Title } from "../../../components/Header/Title"; import { authenticatedFetch } from "../../../utils/authenticated-fetch"; import i18next from "i18next"; -import {useTranslation} from "react-i18next"; -import {Me} from "../../../types/me.ts"; -import {fetchMe} from "../../../utils/fetches/FetchMe.ts"; +import { useTranslation } from "react-i18next"; +import { Me } from "../../../types/me.ts"; import DeadlineGrid from "../../../components/DeadlineView/DeadlineGrid.tsx"; -import {Deadline} from "../../../types/deadline.ts"; -import EditIcon from '@mui/icons-material/Edit'; -import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; +import { Deadline } from "../../../types/deadline.ts"; +import EditIcon from "@mui/icons-material/Edit"; +import CheckIcon from "@mui/icons-material/Check"; +import CloseIcon from "@mui/icons-material/Close"; const API_URL = import.meta.env.VITE_APP_API_HOST; @@ -35,6 +40,7 @@ interface Project { description: string; regex_expressions: string[]; archived: string; + deadlines: Deadline[]; } /** @@ -43,129 +49,86 @@ interface Project { * and submissions of the current user for that project */ export default function ProjectView() { - const location = useLocation(); - const [me, setMe] = useState(null); - const { t } = useTranslation('translation', { keyPrefix: 'projectView' }); + const { projectData, courseData, assignmentText, isAdmin } = + useLoaderData() as { + me: Me; + projectData: Project; + courseData: Course; + assignmentText: string; + isAdmin: boolean; + }; + + const deadlines = projectData["deadlines"]; + + const { t } = useTranslation("translation", { keyPrefix: "projectView" }); const { projectId } = useParams<{ projectId: string }>(); - const [projectData, setProjectData] = useState(null); - const [courseData, setCourseData] = useState(null); - const [assignmentRawText, setAssignmentRawText] = useState(""); - const [deadlines, setDeadlines] = useState([]); - const [alertVisibility, setAlertVisibility] = useState(false) + const [alertVisibility, setAlertVisibility] = useState(false); const [edit, setEdit] = useState(false); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); + const [title, setTitle] = useState(projectData["title"]); + const [description, setDescription] = useState(projectData["description"]); - const navigate = useNavigate() + const navigate = useNavigate(); const deleteProject = () => { authenticatedFetch(`${API_URL}/projects/${projectId}`, { - method: "DELETE" + method: "DELETE", }); - navigate('/projects'); - } + navigate("/projects"); + }; const patchTitleAndDescription = async () => { setEdit(false); const formData = new FormData(); - formData.append('title', title); - formData.append('description', description); + formData.append("title", title); + formData.append("description", description); - const response = await authenticatedFetch(`${API_URL}/projects/${projectId}`, { - method: "PATCH", - body: formData - }); + const response = await authenticatedFetch( + `${API_URL}/projects/${projectId}`, + { + method: "PATCH", + body: formData, + } + ); // Check if the response is ok (status code 2xx) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } - - updateProject(); - } + }; const discardEditTitle = () => { const title = projectData?.title; setEdit(false); - if (title) - setTitle(title); - - if (projectData?.description) - setDescription(projectData?.description); - } - - const updateProject = useCallback(async () => { - authenticatedFetch(`${API_URL}/projects/${projectId}`).then((response) => { - if (response.ok) { - response.json().then((data) => { - const projectData = data["data"]; - setProjectData(projectData); - setTitle(projectData.title); - setDescription(projectData.description); - - const transformedDeadlines = projectData.deadlines.map((deadlineArray: string[]): Deadline => ({ - description: deadlineArray[0], - deadline: deadlineArray[1] - })); - - setDeadlines(transformedDeadlines); + if (title) setTitle(title); - authenticatedFetch( - `${API_URL}/courses/${projectData.course_id}` - ).then((response) => { - if (response.ok) { - response.json().then((data) => { - setCourseData(data["data"]); - }); - } - }); - }); - } - }); - }, [projectId]); + if (projectData?.description) setDescription(projectData?.description); + }; const archiveProject = async () => { const newArchived = !projectData?.archived; const formData = new FormData(); - formData.append('archived', newArchived.toString()); - - await authenticatedFetch(`${API_URL}/projects/${projectId}`, { - method: "PATCH", - body: formData - }) + formData.append("archived", newArchived.toString()); - await updateProject(); - } - - useEffect(() => { - updateProject(); - - authenticatedFetch( - `${API_URL}/projects/${projectId}/assignment?lang=${i18next.resolvedLanguage}` - ).then((response) => { - if (response.ok) { - response.text().then((data) => setAssignmentRawText(data)); + const response = await authenticatedFetch( + `${API_URL}/projects/${projectId}`, + { + method: "PATCH", + body: formData, } - }); + ); - fetchMe().then((data) => { - setMe(data); - }); - - }, [projectId, updateProject]); + if (response.ok) { + navigate(0); + } + }; if (!projectId) return null; return ( - + {projectData && ( @@ -178,96 +141,114 @@ export default function ProjectView() { justifyContent="space-between" alignItems="center" > - { - !edit && <>{projectData.title} - } - { - edit && <> setTitle(event.target.value)}/> - } + {!edit && <>{projectData.title}} + {edit && ( + <> + setTitle(event.target.value)} + /> + + )} {courseData && ( - )} } subheader={ - + - { - !edit - ? <>{projectData.description} - : edit && <> setDescription(event.target.value)}/> - } + {!edit ? ( + <> + {projectData.description} + + ) : ( + edit && ( + <> + + setDescription(event.target.value) + } + /> + + ) + )} } /> - {assignmentRawText} - - { me && me.role === "TEACHER" && ( - edit - ? ( - <> - - - - - - - ) - : ( - setEdit(true)}> - + {assignmentText} + + {isAdmin && + (edit ? ( + <> + + - ) - )} + + + + + ) : ( + setEdit(true)}> + + + ))} )} - {me && me.role === "TEACHER" && ( - - + - + - @@ -275,16 +256,21 @@ export default function ProjectView() { display="flex" flexDirection="row-reverse" pt={2} - width="100%"> - - -
+ +
{ @@ -293,24 +279,26 @@ export default function ProjectView() { }, 4000); }} > - - Are you sure you want to delete this project - + + {t("deleteProjectWarning")} + + - )} @@ -322,5 +310,4 @@ export default function ProjectView() { ); - } From 8b7aae1f0df59977a7236a69fbe08f6e04ce124e Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 23 May 2024 20:46:05 +0200 Subject: [PATCH 371/377] redirecting to homepage when user not logged in (#421) * redirecting to homepage when user not logged in * linting --- frontend/src/App.tsx | 1 + frontend/src/components/Header/Layout.tsx | 41 ++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6d2cad70..a3c39927 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,6 +35,7 @@ const router = createBrowserRouter( > } loader={fetchProjectPage} /> }> + } loader={fetchProjectPage} /> } loader={fetchProjectPage} /> { + if ( + !meData.loggedIn && + !( + location.pathname === "/" || + /\/([a-z]{2})?\/home/.test(location.pathname) + ) + ) { + navigate("/"); + } + }, [meData.loggedIn, location.pathname, navigate]); return ( <> @@ -16,3 +38,20 @@ export default function Layout(): JSX.Element { ); } + +const useEnsureLangCodeInPath = () => { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const pathParts = location.pathname.split("/").filter(Boolean); + const langCode = i18next.resolvedLanguage; + + // Check if the URL starts with the lang code + if (pathParts[0] !== langCode) { + // Prepend the lang code to the path + const newPath = `/${langCode}/${pathParts.join("/")}`; + navigate(newPath); + } + }, [location, navigate]); +}; \ No newline at end of file From d3bcf1fffa706042a4aae683f24f4b1eda4f64c4 Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 23 May 2024 20:46:24 +0200 Subject: [PATCH 372/377] only fetching admins when needed (#420) --- .../src/components/Courses/CourseUtils.tsx | 104 ++++++++++-------- 1 file changed, 61 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 8525f930..d06202ff 100644 --- a/frontend/src/components/Courses/CourseUtils.tsx +++ b/frontend/src/components/Courses/CourseUtils.tsx @@ -41,13 +41,15 @@ export function loggedInToken() { * @returns The username. */ export async function getUser(uid: string): Promise { - return authenticatedFetch(`${apiHost}/users/${getIdFromLink(uid)}`).then((response) => { - if (response.ok) { - return response.json().then((data) => { - return data.data; - }); + return authenticatedFetch(`${apiHost}/users/${getIdFromLink(uid)}`).then( + (response) => { + if (response.ok) { + return response.json().then((data) => { + return data.data; + }); + } } - }) + ); } /** @@ -124,14 +126,14 @@ const fetchData = async (url: string, params?: URLSearchParams) => { export const dataLoaderCourses = async () => { //const params = new URLSearchParams({ 'teacher': loggedInUid() }); - const courses = await fetchData(`courses`); + const courses = await fetchData(`courses`); const projects = await fetchProjectsCourse(courses); const me = await fetchMe(); - for( const c of courses){ - const teacher = await fetchData(`users/${c.teacher}`) - c.teacher = teacher.display_name + for (const c of courses) { + const teacher = await fetchData(`users/${c.teacher}`); + c.teacher = teacher.display_name; } - return {courses, projects, me} + return { courses, projects, me }; }; /** @@ -139,7 +141,7 @@ export const dataLoaderCourses = async () => { * @param courses - All the courses * @returns the projects */ -export async function fetchProjectsCourse (courses:Course[]) { +export async function fetchProjectsCourse(courses: Course[]) { const projectPromises = courses.map((course) => authenticatedFetch( `${apiHost}/projects?course_id=${getIdFromLink(course.course_id)}` @@ -149,30 +151,32 @@ export async function fetchProjectsCourse (courses:Course[]) { const projectResults = await Promise.all(projectPromises); const projectsMap: { [courseId: string]: ProjectDetail[] } = {}; for await (const [index, result] of projectResults.entries()) { - projectsMap[getIdFromLink(courses[index].course_id)] = await Promise.all(result.data.map(async (item: Project) => { - const projectRes = await authenticatedFetch(item.project_id); - if (projectRes.status !== 200) { - throw new Response("Failed to fetch project data", { - status: projectRes.status, - }); - } - const projectJson = await projectRes.json(); - const projectData = projectJson.data; - let projectDeadlines = []; - if (projectData.deadlines) { - projectDeadlines = projectData.deadlines.map( - ([description, dateString]: [string, string]) => ({ - description, - date: new Date(dateString), - }) - ); - } - const project: ProjectDetail = { - ...item, - deadlines: projectDeadlines, - }; - return project; - })); + projectsMap[getIdFromLink(courses[index].course_id)] = await Promise.all( + result.data.map(async (item: Project) => { + const projectRes = await authenticatedFetch(item.project_id); + if (projectRes.status !== 200) { + throw new Response("Failed to fetch project data", { + status: projectRes.status, + }); + } + const projectJson = await projectRes.json(); + const projectData = projectJson.data; + let projectDeadlines = []; + if (projectData.deadlines) { + projectDeadlines = projectData.deadlines.map( + ([description, dateString]: [string, string]) => ({ + description, + date: new Date(dateString), + }) + ); + } + const project: ProjectDetail = { + ...item, + deadlines: projectDeadlines, + }; + return project; + }) + ); } return { ...projectsMap }; } @@ -228,7 +232,7 @@ const dataLoaderStudents = async (courseId: string) => { const fetchMes = async (uids: string[]) => { return Promise.all(uids.map((uid) => getUser(uid))); -} +}; export const dataLoaderCourseDetail = async ({ params, @@ -239,14 +243,28 @@ export const dataLoaderCourseDetail = async ({ if (!courseId) { throw new Error("Course ID is undefined."); } + const me = await fetchMe(); + const course = await dataLoaderCourse(courseId); + + const courseAdminuids = course["admins"].map((admin: string) => { + const urlSplit = admin.split("/"); + return urlSplit[urlSplit.length - 1]; + }); + const projects = await dataLoaderProjects(courseId); - const admins = await dataLoaderAdmins(courseId); + let adminMes: Me[] = []; + if (me.uid === course.teacher || courseAdminuids.includes(me.uid)) { + const admins = await dataLoaderAdmins(courseId); + const adminUids = admins.map((admin: { uid: string }) => + getIdFromLink(admin.uid) + ); + adminMes = await fetchMes([course.teacher, ...adminUids]); + } const students = await dataLoaderStudents(courseId); - const admin_uids = admins.map((admin: {uid: string}) => getIdFromLink(admin.uid)); - const student_uids = students.map((student: {uid: string}) => getIdFromLink(student.uid)); - const adminMes = await fetchMes([course.teacher, ...admin_uids]); + const student_uids = students.map((student: { uid: string }) => + getIdFromLink(student.uid) + ); const studentMes = await fetchMes(student_uids); - const me = await fetchMe(); - return { course, projects, adminMes, studentMes, me}; + return { course, projects, adminMes, studentMes, me }; }; From dcb556fa208997b577ea18c766a35a6ed999051e Mon Sep 17 00:00:00 2001 From: Aron Buzogany <108480125+AronBuzogany@users.noreply.github.com> Date: Thu, 23 May 2024 21:01:03 +0200 Subject: [PATCH 373/377] Fixed url flickering (#423) * redirecting to homepage when user not logged in * linting * fixed url flickering --- frontend/src/components/Header/Layout.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Header/Layout.tsx b/frontend/src/components/Header/Layout.tsx index 959d6166..2b3097c7 100644 --- a/frontend/src/components/Header/Layout.tsx +++ b/frontend/src/components/Header/Layout.tsx @@ -24,10 +24,11 @@ export default function Layout(): JSX.Element { !meData.loggedIn && !( location.pathname === "/" || - /\/([a-z]{2})?\/home/.test(location.pathname) + /\/([a-z]{2})\/home/.test(location.pathname) || + location.pathname === `/${i18next.resolvedLanguage}` ) ) { - navigate("/"); + navigate(`${i18next.resolvedLanguage}/home`); } }, [meData.loggedIn, location.pathname, navigate]); @@ -54,4 +55,4 @@ const useEnsureLangCodeInPath = () => { navigate(newPath); } }, [location, navigate]); -}; \ No newline at end of file +}; From 26692db4e542d23621f18f9ac8adf868028ba70e Mon Sep 17 00:00:00 2001 From: Warre Provoost <133233646+warreprovoost@users.noreply.github.com> Date: Thu, 23 May 2024 21:02:30 +0200 Subject: [PATCH 374/377] fix seeder path (#422) --- backend/seeder/seeder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/seeder/seeder.py b/backend/seeder/seeder.py index d56d0e0f..2e90341f 100644 --- a/backend/seeder/seeder.py +++ b/backend/seeder/seeder.py @@ -222,7 +222,7 @@ def populate_course_projects(session, course_id, students): # Write assignment.md file assignment_content = fake.text() assignment_file_path = os.path.join( - UPLOAD_URL, "projects", str(project_id), "assignment.md") + UPLOAD_URL, str(project_id), "assignment.md") os.makedirs(os.path.dirname(assignment_file_path), exist_ok=True) with open(assignment_file_path, "w", encoding="utf-8") as assignment_file: assignment_file.write(assignment_content) @@ -236,7 +236,7 @@ def populate_project_submissions(session, students, project_id): session.add_all(submissions) session.commit() for submission in submissions: - submission_directory = os.path.join(UPLOAD_URL, "projects", str( + submission_directory = os.path.join(UPLOAD_URL, str( project_id), "submissions", str(submission.submission_id), "submission") os.makedirs(submission_directory, exist_ok=True) submission_file_path = os.path.join( From 41567cecc4480802bddc04d031d3bc3a29aef719 Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 21:06:10 +0200 Subject: [PATCH 375/377] added explanation (#425) --- documentation/docs/evaluators/custom_evaluator.md | 4 +++- .../current/evaluators/custom_evaluator.md | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/documentation/docs/evaluators/custom_evaluator.md b/documentation/docs/evaluators/custom_evaluator.md index d7dd2552..017ee51e 100644 --- a/documentation/docs/evaluators/custom_evaluator.md +++ b/documentation/docs/evaluators/custom_evaluator.md @@ -1 +1,3 @@ -# Custom evaluator \ No newline at end of file +# Custom evaluator +When selecting the custom runner the user has to provide his own Dockerfile. +This gives freedom to do everything you want with it, and it is completely customizable to your own needs. \ No newline at end of file diff --git a/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md index d7dd2552..b24c64c4 100644 --- a/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md +++ b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/custom_evaluator.md @@ -1 +1,2 @@ -# Custom evaluator \ No newline at end of file +# Custom evaluator +Bij het selecteren van de aangepaste runner moet de gebruiker zijn eigen Dockerfile aanleveren. Dit geeft de vrijheid om alles te doen wat je wilt, en het is volledig aan te passen aan je eigen behoeften. \ No newline at end of file From 5032fd1a47fcb8a58c866b31e5c2f6e9cbd684bd Mon Sep 17 00:00:00 2001 From: Gerwoud Van den Eynden <62761483+Gerwoud@users.noreply.github.com> Date: Thu, 23 May 2024 21:18:56 +0200 Subject: [PATCH 376/377] added general evaluator documentation (#424) * added general evaluator documentation * fix 1 * fix 2 * fix yallah * final fix i believe --- documentation/docs/evaluators/general_evaluator.md | 8 +++++++- .../current/evaluators/general_evaluator.md | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/documentation/docs/evaluators/general_evaluator.md b/documentation/docs/evaluators/general_evaluator.md index eb8ccedd..0b644869 100644 --- a/documentation/docs/evaluators/general_evaluator.md +++ b/documentation/docs/evaluators/general_evaluator.md @@ -1 +1,7 @@ -# General evaluator \ No newline at end of file +# General evaluator +The general runner is capable of running the following languages with only the standard libraries included +- Python +- Ruby +- Java + +All the user has to is upload a run_tests.sh in the project files and the script will run when a submission is uploaded by a student. \ No newline at end of file diff --git a/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md index eb8ccedd..43c722fd 100644 --- a/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md +++ b/documentation/i18n/nl/docusaurus-plugin-content-docs/current/evaluators/general_evaluator.md @@ -1 +1,8 @@ -# General evaluator \ No newline at end of file +# General evaluator +De algemene runner is in staat om de volgende talen uit te voeren met enkel de standaardbibliotheken inbegrepen +- Javascript +- Python +- Ruby +- Java + +Het enige wat de gebruiker hoeft te doen is een run_tests.sh-bestand in de projectbestanden te uploaden en het script wordt uitgevoerd wanneer een inzending door een student wordt geüpload. \ No newline at end of file From 1b67e31450268a329e66a482be829c5afa8448bd Mon Sep 17 00:00:00 2001 From: Siebe Vlietinck <71773032+Vucis@users.noreply.github.com> Date: Thu, 23 May 2024 21:33:57 +0200 Subject: [PATCH 377/377] Header tests frontend (#391) * file structure * added hompage not logged in tests and header login functionality test * linting + pass current tests hopefully * more linting * extra header tests * other way of testing center button * third option * language test * ignore screenshots * logged in test * formatting * extra formatting * deleted unused files * intercept doesn't work on runner * removed comments --- frontend/.gitignore | 1 + frontend/cypress/e2e/Header.cy.tsx | 31 +++++++ frontend/cypress/e2e/HomePageTests.cy.tsx | 20 ++++ frontend/package-lock.json | 106 ++++++++++++++++++++++ frontend/package.json | 1 + frontend/tests/unit/header.test.tsx | 7 +- frontend/tests/utils/api.ts | 0 frontend/tests/utils/me-fixture.ts | 5 + frontend/tests/utils/utils.ts | 0 9 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 frontend/cypress/e2e/Header.cy.tsx create mode 100644 frontend/cypress/e2e/HomePageTests.cy.tsx delete mode 100644 frontend/tests/utils/api.ts create mode 100644 frontend/tests/utils/me-fixture.ts delete mode 100644 frontend/tests/utils/utils.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index 0888a05a..fd154402 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,7 @@ dist dist-ssr *.local coverage +screenshots .env # Editor directories and files diff --git a/frontend/cypress/e2e/Header.cy.tsx b/frontend/cypress/e2e/Header.cy.tsx new file mode 100644 index 00000000..c810af0c --- /dev/null +++ b/frontend/cypress/e2e/Header.cy.tsx @@ -0,0 +1,31 @@ +describe("Header look not logged in", () => { + it("App name in header", () => { + cy.visit("/"); + cy.get("header").contains("Peristerónas"); + }); + it("Login in header", () => { + cy.visit("/"); + cy.get("header").contains("Login"); + }); + it("Language in header", () => { + cy.visit("/"); + cy.get("header").contains("en"); + }); +}); + +describe("Header functionality not logged in", () => { + it("Header Login", () => { + cy.visit("/"); + cy.contains("Login") + .should("be.visible") + .should("have.attr", "href") + .and("include", "login.microsoftonline.com"); + }); + + it("Header change language en -> nl", () => { + cy.visit("/"); + cy.contains("en").should("be.visible").click(); + cy.contains("Nederlands").should("be.visible").click(); + cy.contains("nl").should("be.visible"); + }); +}); diff --git a/frontend/cypress/e2e/HomePageTests.cy.tsx b/frontend/cypress/e2e/HomePageTests.cy.tsx new file mode 100644 index 00000000..7676ac39 --- /dev/null +++ b/frontend/cypress/e2e/HomePageTests.cy.tsx @@ -0,0 +1,20 @@ +describe("Homepage functionality not logged in ", () => { + it("Header Visible", () => { + cy.visit("/"); + cy.contains("Peristerónas"); + cy.get("header").should("be.visible"); + }); + + it("Center button Login", () => { + cy.visit("/"); + cy.contains("Peristerónas"); + cy.contains( + "Welcome to Peristerónas, the online submission platform of UGent" + ) + .parent() + .contains("Login") + .should("be.visible") + .should("have.attr", "href") + .and("include", "login.microsoftonline.com"); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d060e871..dc2cdc9a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "styled-components": "^6.1.8" }, "devDependencies": { + "@testing-library/react": "^15.0.7", "@types/downloadjs": "^1.4.6", "@types/history": "^4.7.11", "@types/react": "^18.2.55", @@ -2484,6 +2485,87 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.1.0.tgz", + "integrity": "sha512-wdsYKy5zupPyLCW2Je5DLHSxSfbIp6h80WoHOQc+RPtmPGA52O9x5MJEkv92Sjonpq+poOAtUKhh1kBGAXBrNA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "15.0.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-15.0.7.tgz", + "integrity": "sha512-cg0RvEdD1TIhhkm1IeYMQxrzy0MtUNfa3minv4MjbgcYzJAZ7yD0i0lwoPOTPr+INtiXFezt2o8xMSnyHhEn2Q==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^10.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3499,6 +3581,15 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -4830,6 +4921,12 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -8178,6 +8275,15 @@ "node": ">=10" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.10", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", diff --git a/frontend/package.json b/frontend/package.json index cb56ab96..b2c066b0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -41,6 +41,7 @@ "styled-components": "^6.1.8" }, "devDependencies": { + "@testing-library/react": "^15.0.7", "@types/downloadjs": "^1.4.6", "@types/history": "^4.7.11", "@types/react": "^18.2.55", diff --git a/frontend/tests/unit/header.test.tsx b/frontend/tests/unit/header.test.tsx index f4e6fdd9..76834646 100644 --- a/frontend/tests/unit/header.test.tsx +++ b/frontend/tests/unit/header.test.tsx @@ -1,3 +1,6 @@ -import { test } from "vitest"; +import { describe, test } from "vitest"; +//import { render } from "@testing-library/react"; -test.todo("Header test"); +describe("Header", () => { + test.todo("Header test"); +}); diff --git a/frontend/tests/utils/api.ts b/frontend/tests/utils/api.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/frontend/tests/utils/me-fixture.ts b/frontend/tests/utils/me-fixture.ts new file mode 100644 index 00000000..ad160476 --- /dev/null +++ b/frontend/tests/utils/me-fixture.ts @@ -0,0 +1,5 @@ +export default { + uid: "TestUID", + role: "STUDENT", + display_name: "Terry Tester", +}; diff --git a/frontend/tests/utils/utils.ts b/frontend/tests/utils/utils.ts deleted file mode 100644 index e69de29b..00000000