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/backend/db_construct.sql b/backend/db_construct.sql index 30744372..28afec28 100644 --- a/backend/db_construct.sql +++ b/backend/db_construct.sql @@ -1,4 +1,5 @@ CREATE TYPE role AS ENUM ('STUDENT', 'TEACHER', 'ADMIN'); +CREATE TYPE submission_status AS ENUM ('SUCCESS', 'LATE', 'FAIL', 'RUNNING'); CREATE TABLE users ( uid VARCHAR(255), @@ -56,10 +57,10 @@ 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 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/__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/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..060587c7 100644 --- a/backend/project/endpoints/projects/project_detail.py +++ b/backend/project/endpoints/projects/project_detail.py @@ -3,19 +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 +from project.utils.authentication import authorize_teacher_or_project_admin, \ + authorize_teacher_of_project, authorize_project_visible + +from project.endpoints.projects.endpoint_parser import parse_project_params -API_URL = getenv('API_HOST') +API_URL = os.getenv('API_HOST') RESPONSE_URL = urljoin(API_URL, "projects") +UPLOAD_FOLDER = os.getenv('UPLOAD_URL') + class ProjectDetail(Resource): """ @@ -44,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, diff --git a/backend/project/endpoints/submissions.py b/backend/project/endpoints/submissions.py index 34ae2282..ebc22a88 100644 --- a/backend/project/endpoints/submissions.py +++ b/backend/project/endpoints/submissions.py @@ -8,13 +8,15 @@ 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 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") @@ -119,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() @@ -210,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() @@ -276,4 +284,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 467534ba..af75eb39 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) @@ -44,39 +45,49 @@ 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. """ uid = request.json.get('uid') +<<<<<<< HEAD role = request.args.get("role") +======= + is_teacher = request.json.get('is_teacher') + is_admin = request.json.get('is_admin') + url = f"{API_URL}/users" +>>>>>>> development if role is None or uid is None: return { "message": "Invalid request data!", "correct_format": { "uid": "User ID (string)", +<<<<<<< HEAD "role": "User role (string)" },"url": f"{API_URL}/users" +======= + "is_teacher": "Teacher status (boolean)", + "is_admin": "Admin status (boolean)" + },"url": url +>>>>>>> development }, 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 and role new_user = userModel(uid=uid, role=role) 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): @@ -99,7 +110,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/models/submission.py b/backend/project/models/submission.py index cda2620d..1587f80f 100644 --- a/backend/project/models/submission.py +++ b/backend/project/models/submission.py @@ -1,9 +1,25 @@ """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, + Float, + 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, @@ -20,7 +36,9 @@ 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: bool = Column(Boolean, nullable=False) + submission_status: SubmissionStatus = Column( + EnumField(SubmissionStatus, name="submission_status"), + nullable=False) diff --git a/backend/project/utils/authentication.py b/backend/project/utils/authentication.py index 4d1ff2a1..e73ab9c5 100644 --- a/backend/project/utils/authentication.py +++ b/backend/project/utils/authentication.py @@ -3,36 +3,42 @@ """ 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 +<<<<<<< HEAD from project.models.user import User,Role 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.models.user import User +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 +>>>>>>> development 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,42 +48,57 @@ 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 +<<<<<<< HEAD # Use the Enum here role = Role.STUDENT if user_info["jobTitle"] != None: role = Role.TEACHER +======= + is_teacher = False + if user_info["jobTitle"] is not None: + is_teacher = True + +>>>>>>> development # 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: - # 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") + db.session.rollback() + abort(make_response(({"message": + """An unexpected database error occured + while creating the user during authentication"""}, 500))) return auth_user_id +<<<<<<< HEAD def is_teacher(auth_user_id): @@ -182,6 +203,8 @@ def get_course_of_submission(submission_id): abort_with_message(404, f"Submission with id: {submission_id} not found") return get_course_of_project(submission.project_id) +======= +>>>>>>> development def login_required(f): """ @@ -195,6 +218,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. @@ -206,7 +245,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 @@ -222,7 +263,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 @@ -236,10 +277,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 @@ -255,8 +298,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 @@ -271,11 +315,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 @@ -290,10 +335,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 @@ -301,7 +347,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) @@ -309,89 +356,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/requirements.txt b/backend/requirements.txt index 9e9dc90a..1bbc2e9e 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.31.0 +waitress 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.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 55c564c2..fe9d3961 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -9,7 +9,7 @@ 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 +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 ) ] @@ -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 a094e6cc..41f99e12 100644 --- a/backend/tests/endpoints/conftest.py +++ b/backend/tests/endpoints/conftest.py @@ -6,12 +6,13 @@ from zoneinfo import ZoneInfo import pytest 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 +from project.models.submission import Submission, SubmissionStatus from project.models.project import Project @@ -26,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 @@ -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): """ @@ -178,8 +200,11 @@ def client(app): def valid_teacher_entry(session): """A valid teacher for testing that's already in the db""" teacher = User(uid="Bart", role=Role.TEACHER) - session.add(teacher) - session.commit() + 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..a900bb84 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" @@ -62,13 +65,14 @@ 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 ### 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,10 +96,12 @@ 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)" + assert data["message"] == "Invalid grading (not a valid float)" def test_patch_submission_correct_teacher(self, client: FlaskClient, session: Session): """Test patching a submission""" @@ -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" @@ -113,10 +123,8 @@ 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' } - - # 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 e93f8357..d19cdb41 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.""" if valid_user_entry.role == Role.TEACHER: @@ -129,14 +144,14 @@ def test_patch_user(self, client, valid_user_entry): }) assert response.status_code == 403 # Patching a user is never necessary and thus not allowed - 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={ 'role': Role.TEACHER }) assert response.status_code == 403 # Patching is not allowed - 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) if valid_user_form["role"] == Role.TEACHER: @@ -149,7 +164,7 @@ def test_patch_non_json(self, client, 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?role=admin", headers={"Authorization":"teacher1"}) + 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 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() 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