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//latest-per-user', view_func=SubmissionPerUser.as_view('latest_per_user') ) + +project_bp.add_url_rule( + '/projects//groups', + view_func=Groups.as_view('groups') +) 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. diff --git a/backend/project/endpoints/projects/projects.py b/backend/project/endpoints/projects/projects.py index 27b603a3..03d46112 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,12 @@ 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)) + 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 # Return the projects data["message"] = "Successfully fetched the projects" 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/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/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/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( 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/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"]) 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 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 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/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/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 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 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. 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 c3a967a0..a3c39927 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, @@ -21,8 +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( @@ -34,11 +35,20 @@ const router = createBrowserRouter( > } loader={fetchProjectPage} /> }> + } loader={fetchProjectPage} /> } loader={fetchProjectPage} /> - } loader={dataLoaderCourses}/> + } + loader={dataLoaderCourses} + /> - } loader={dataLoaderCourseDetail} /> + } + loader={dataLoaderCourseDetail} + /> } /> - }> - } loader={fetchProjectForm}/> + } + loader={loadProjectViewData} + > + } + loader={fetchProjectForm} + /> - , - ), + + ) ); /** 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 ( + <> + + + + +
+ {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 6ba52b63..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[]; @@ -461,7 +462,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}` ); }; 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 ( diff --git a/frontend/src/components/Courses/CourseUtils.tsx b/frontend/src/components/Courses/CourseUtils.tsx index 118f8b88..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,13 +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); - return { course, projects, adminMes, studentMes }; + 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 diff --git a/frontend/src/components/Header/Layout.tsx b/frontend/src/components/Header/Layout.tsx index b7852b91..2b3097c7 100644 --- a/frontend/src/components/Header/Layout.tsx +++ b/frontend/src/components/Header/Layout.tsx @@ -1,6 +1,13 @@ -import { Outlet, useLoaderData } from "react-router-dom"; +import { + Outlet, + useLoaderData, + useLocation, + useNavigate, +} from "react-router-dom"; import { Header } from "./Header.tsx"; import { Me } from "../../types/me.ts"; +import { useEffect } from "react"; +import i18next from "i18next"; /** * Basic layout component that will be used on all routes. @@ -8,6 +15,22 @@ import { Me } from "../../types/me.ts"; */ export default function Layout(): JSX.Element { const meData: Me = useLoaderData() as Me; + useEnsureLangCodeInPath(); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + if ( + !meData.loggedIn && + !( + location.pathname === "/" || + /\/([a-z]{2})\/home/.test(location.pathname) || + location.pathname === `/${i18next.resolvedLanguage}` + ) + ) { + navigate(`${i18next.resolvedLanguage}/home`); + } + }, [meData.loggedIn, location.pathname, navigate]); return ( <> @@ -16,3 +39,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]); +}; 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/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; }) { + 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( 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 ( diff --git a/frontend/src/pages/project/projectView/ProjectView.tsx b/frontend/src/pages/project/projectView/ProjectView.tsx index 8b1f8dd8..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); + if (title) setTitle(title); - 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) => { - 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 - }) - - await updateProject(); - } - - useEffect(() => { - updateProject(); + formData.append("archived", newArchived.toString()); - 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,42 +141,67 @@ 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} - - { - edit && ( + {assignmentText} + + {isAdmin && + (edit ? ( <> @@ -222,55 +210,45 @@ export default function ProjectView() { - ) - } - { - !edit && ( + ) : ( setEdit(true)}> - ) - } + ))} )} - {me && me.role == "TEACHER" && ( - - + - + - @@ -278,16 +256,21 @@ export default function ProjectView() { display="flex" flexDirection="row-reverse" pt={2} - width="100%"> - - -
+ +
{ @@ -296,24 +279,26 @@ export default function ProjectView() { }, 4000); }} > - - Are you sure you want to delete this project - + + {t("deleteProjectWarning")} + + - )} @@ -325,5 +310,4 @@ export default function ProjectView() { ); - } 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();